diff --git a/jupyterlab_git/git.py b/jupyterlab_git/git.py
index 78636a40..0b1283df 100644
--- a/jupyterlab_git/git.py
+++ b/jupyterlab_git/git.py
@@ -1688,6 +1688,20 @@ async def remote_remove(self, path, name):
 
         return response
 
+    def read_file(self, path):
+        """
+        Reads file content located at path and returns it as a string
+
+        path: str
+            The path of the file
+        """
+        try:
+            file = pathlib.Path(path)
+            content = file.read_text()
+            return {"code": 0, "content": content}
+        except BaseException as error:
+            return {"code": -1, "content": ""}
+
     async def ensure_gitignore(self, path):
         """Handle call to ensure .gitignore file exists and the
         next append will be on a new line (this means an empty file
@@ -1728,6 +1742,29 @@ async def ignore(self, path, file_path):
             return {"code": -1, "message": str(error)}
         return {"code": 0}
 
+    async def write_gitignore(self, path, content):
+        """
+        Handle call to overwrite .gitignore.
+        Takes the .gitignore file and clears its previous contents
+        Writes the new content onto the file
+
+        path: str
+            Top Git repository path
+        content: str
+            New file contents
+        """
+        try:
+            res = await self.ensure_gitignore(path)
+            if res["code"] != 0:
+                return res
+            gitignore = pathlib.Path(path) / ".gitignore"
+            if content and content[-1] != "\n":
+                content += "\n"
+            gitignore.write_text(content)
+        except BaseException as error:
+            return {"code": -1, "message": str(error)}
+        return {"code": 0}
+
     async def version(self):
         """Return the Git command version.
 
diff --git a/jupyterlab_git/handlers.py b/jupyterlab_git/handlers.py
index afac6aa5..ef316070 100644
--- a/jupyterlab_git/handlers.py
+++ b/jupyterlab_git/handlers.py
@@ -810,6 +810,17 @@ class GitIgnoreHandler(GitHandler):
     Handler to manage .gitignore
     """
 
+    @tornado.web.authenticated
+    async def get(self, path: str = ""):
+        """
+        GET read content in .gitignore
+        """
+        local_path = self.url2localpath(path)
+        body = self.git.read_file(local_path + "/.gitignore")
+        if body["code"] != 0:
+            self.set_status(500)
+        self.finish(json.dumps(body))
+
     @tornado.web.authenticated
     async def post(self, path: str = ""):
         """
@@ -818,8 +829,11 @@ async def post(self, path: str = ""):
         local_path = self.url2localpath(path)
         data = self.get_json_body()
         file_path = data.get("file_path", None)
+        content = data.get("content", None)
         use_extension = data.get("use_extension", False)
-        if file_path:
+        if content:
+            body = await self.git.write_gitignore(local_path, content)
+        elif file_path:
             if use_extension:
                 suffixes = Path(file_path).suffixes
                 if len(suffixes) > 0:
@@ -827,7 +841,6 @@ async def post(self, path: str = ""):
             body = await self.git.ignore(local_path, file_path)
         else:
             body = await self.git.ensure_gitignore(local_path)
-
         if body["code"] != 0:
             self.set_status(500)
         self.finish(json.dumps(body))
diff --git a/schema/plugin.json b/schema/plugin.json
index da4b654a..c0dba936 100644
--- a/schema/plugin.json
+++ b/schema/plugin.json
@@ -76,6 +76,12 @@
       "title": "Open files behind warning",
       "description": "If true, a popup dialog will be displayed if a user opens a file that is behind its remote branch version, or if an opened file has updates on the remote branch.",
       "default": true
+    },
+    "hideHiddenFileWarning": {
+      "type": "boolean",
+      "title": "Hide hidden file warning",
+      "description": "If true, the warning popup when opening the .gitignore file without hidden files will not be displayed.",
+      "default": false
     }
   },
   "jupyter.lab.shortcuts": [
diff --git a/src/__tests__/commands.spec.tsx b/src/__tests__/commands.spec.tsx
index ccbf4cf2..cb2d698e 100644
--- a/src/__tests__/commands.spec.tsx
+++ b/src/__tests__/commands.spec.tsx
@@ -60,7 +60,7 @@ describe('git-commands', () => {
     addCommands(
       app as JupyterFrontEnd,
       model,
-      new CodeMirrorEditorFactory().newDocumentEditor,
+      new CodeMirrorEditorFactory(),
       new EditorLanguageRegistry(),
       mockedFileBrowserModel,
       null as any,
diff --git a/src/commandsAndMenu.tsx b/src/commandsAndMenu.tsx
index dbf0c58a..4e83a469 100644
--- a/src/commandsAndMenu.tsx
+++ b/src/commandsAndMenu.tsx
@@ -8,7 +8,12 @@ import {
   showDialog,
   showErrorMessage
 } from '@jupyterlab/apputils';
-import { CodeEditor } from '@jupyterlab/codeeditor';
+import {
+  CodeEditor,
+  CodeEditorWrapper,
+  IEditorFactoryService
+} from '@jupyterlab/codeeditor';
+import { IEditorLanguageRegistry } from '@jupyterlab/codemirror';
 import { PathExt, URLExt } from '@jupyterlab/coreutils';
 import { FileBrowser, FileBrowserModel } from '@jupyterlab/filebrowser';
 import { Contents } from '@jupyterlab/services';
@@ -16,12 +21,13 @@ import { ISettingRegistry } from '@jupyterlab/settingregistry';
 import { ITerminal } from '@jupyterlab/terminal';
 import { ITranslator, TranslationBundle } from '@jupyterlab/translation';
 import {
-  closeIcon,
   ContextMenuSvg,
   Toolbar,
-  ToolbarButton
+  ToolbarButton,
+  closeIcon,
+  saveIcon
 } from '@jupyterlab/ui-components';
-import { ArrayExt } from '@lumino/algorithm';
+import { ArrayExt, find } from '@lumino/algorithm';
 import { CommandRegistry } from '@lumino/commands';
 import { PromiseDelegate } from '@lumino/coreutils';
 import { Message } from '@lumino/messaging';
@@ -29,14 +35,15 @@ import { ContextMenu, DockPanel, Menu, Panel, Widget } from '@lumino/widgets';
 import * as React from 'react';
 import { CancelledError } from './cancelledError';
 import { BranchPicker } from './components/BranchPicker';
+import { CONTEXT_COMMANDS } from './components/FileList';
+import { ManageRemoteDialogue } from './components/ManageRemoteDialogue';
 import { NewTagDialogBox } from './components/NewTagDialog';
-import { DiffModel } from './components/diff/model';
 import { createPlainTextDiff } from './components/diff/PlainTextDiff';
 import { PreviewMainAreaWidget } from './components/diff/PreviewMainAreaWidget';
-import { CONTEXT_COMMANDS } from './components/FileList';
-import { ManageRemoteDialogue } from './components/ManageRemoteDialogue';
+import { DiffModel } from './components/diff/model';
 import { AUTH_ERROR_MESSAGES, requestAPI } from './git';
-import { getDiffProvider, GitExtension } from './model';
+import { GitExtension, getDiffProvider } from './model';
+import { showDetails, showError } from './notifications';
 import {
   addIcon,
   diffIcon,
@@ -50,10 +57,8 @@ import {
 import { CommandIDs, ContextCommandIDs, Git, IGitExtension } from './tokens';
 import { AdvancedPushForm } from './widgets/AdvancedPushForm';
 import { GitCredentialsForm } from './widgets/CredentialsBox';
-import { discardAllChanges } from './widgets/discardAllChanges';
 import { CheckboxForm } from './widgets/GitResetToRemoteForm';
-import { IEditorLanguageRegistry } from '@jupyterlab/codemirror';
-import { showDetails, showError } from './notifications';
+import { discardAllChanges } from './widgets/discardAllChanges';
 
 export interface IGitCloneArgs {
   /**
@@ -126,7 +131,7 @@ function pluralizedContextLabel(singular: string, plural: string) {
 export function addCommands(
   app: JupyterFrontEnd,
   gitModel: GitExtension,
-  editorFactory: CodeEditor.Factory,
+  editorFactory: IEditorFactoryService,
   languageRegistry: IEditorLanguageRegistry,
   fileBrowserModel: FileBrowserModel,
   settings: ISettingRegistry.ISettings,
@@ -314,13 +319,129 @@ export function addCommands(
     }
   });
 
+  async function showGitignore(error: any) {
+    const model = new CodeEditor.Model({});
+    const repoPath = gitModel.getRelativeFilePath();
+    const id = repoPath + '/.git-ignore';
+    const contentData = await gitModel.readGitIgnore();
+
+    const gitIgnoreWidget = find(
+      shell.widgets(),
+      shellWidget => shellWidget.id === id
+    );
+    if (gitIgnoreWidget) {
+      shell.activateById(id);
+      return;
+    }
+    model.sharedModel.setSource(contentData ? contentData : '');
+    const editor = new CodeEditorWrapper({
+      factory: editorFactory.newDocumentEditor.bind(editorFactory),
+      model: model
+    });
+    const modelChangedSignal = model.sharedModel.changed;
+    editor.disposed.connect(() => {
+      model.dispose();
+    });
+    const preview = new MainAreaWidget({
+      content: editor
+    });
+
+    preview.title.label = '.gitignore';
+    preview.id = id;
+    preview.title.icon = gitIcon;
+    preview.title.closable = true;
+    preview.title.caption = repoPath + '/.gitignore';
+    const saveButton = new ToolbarButton({
+      icon: saveIcon,
+      onClick: async () => {
+        if (saved) {
+          return;
+        }
+        const newContent = model.sharedModel.getSource();
+        try {
+          await gitModel.writeGitIgnore(newContent);
+          preview.title.className = '';
+          saved = true;
+        } catch (error) {
+          console.log('Could not save .gitignore');
+        }
+      },
+      tooltip: trans.__('Saves .gitignore')
+    });
+    let saved = true;
+    preview.toolbar.addItem('save', saveButton);
+    shell.add(preview);
+    modelChangedSignal.connect(() => {
+      if (saved) {
+        saved = false;
+        preview.title.className = 'not-saved';
+      }
+    });
+  }
+
+  /* Helper: Show gitignore hidden file */
+  async function showGitignoreHiddenFile(error: any, hidePrompt: boolean) {
+    if (hidePrompt) {
+      return showGitignore(error);
+    }
+    const result = await showDialog({
+      title: trans.__('Warning: The .gitignore file is a hidden file.'),
+      body: (
+        <div>
+          {trans.__(
+            'Hidden files by default cannot be accessed with the regular code editor. In order to open the .gitignore file you must:'
+          )}
+          <ol>
+            <li>
+              {trans.__(
+                'Print the command below to create a jupyter_server_config.py file with defaults commented out. If you already have the file located in .jupyter, skip this step.'
+              )}
+              <div style={{ padding: '0.5rem' }}>
+                {'jupyter server --generate-config'}
+              </div>
+            </li>
+            <li>
+              {trans.__(
+                'Open jupyter_server_config.py, uncomment out the following line and set it to True:'
+              )}
+              <div style={{ padding: '0.5rem' }}>
+                {'c.ContentsManager.allow_hidden = False'}
+              </div>
+            </li>
+          </ol>
+        </div>
+      ),
+      buttons: [
+        Dialog.cancelButton({ label: trans.__('Cancel') }),
+        Dialog.okButton({ label: trans.__('Show .gitignore file anyways') })
+      ],
+      checkbox: {
+        label: trans.__('Do not show this warning again'),
+        checked: false
+      }
+    });
+    if (result.button.accept) {
+      settings.set('hideHiddenFileWarning', result.isChecked);
+      showGitignore(error);
+    }
+  }
+
   /** Add git open gitignore command */
   commands.addCommand(CommandIDs.gitOpenGitignore, {
     label: trans.__('Open .gitignore'),
     caption: trans.__('Open .gitignore'),
     isEnabled: () => gitModel.pathRepository !== null,
     execute: async () => {
-      await gitModel.ensureGitignore();
+      try {
+        await gitModel.ensureGitignore();
+      } catch (error: any) {
+        if (error?.name === 'hiddenFile') {
+          await showGitignoreHiddenFile(
+            error,
+            settings.composite['hideHiddenFileWarning'] as boolean
+          );
+        }
+      }
     }
   });
 
@@ -586,7 +707,7 @@ export function addCommands(
           (options =>
             createPlainTextDiff({
               ...options,
-              editorFactory,
+              editorFactory: editorFactory.newInlineEditor.bind(editorFactory),
               languageRegistry
             })));
 
@@ -1523,7 +1644,16 @@ export function addCommands(
       const { files } = args as any as CommandArguments.IGitContextAction;
       for (const file of files) {
         if (file) {
-          await gitModel.ignore(file.to, false);
+          try {
+            await gitModel.ignore(file.to, false);
+          } catch (error: any) {
+            if (error?.name === 'hiddenFile') {
+              await showGitignoreHiddenFile(
+                error,
+                settings.composite['hideHiddenFileWarning'] as boolean
+              );
+            }
+          }
         }
       }
     }
diff --git a/src/components/diff/PreviewMainAreaWidget.ts b/src/components/diff/PreviewMainAreaWidget.ts
index 610e6a90..e5533876 100644
--- a/src/components/diff/PreviewMainAreaWidget.ts
+++ b/src/components/diff/PreviewMainAreaWidget.ts
@@ -1,4 +1,4 @@
-import { MainAreaWidget } from '@jupyterlab/apputils/lib/mainareawidget';
+import { MainAreaWidget } from '@jupyterlab/apputils';
 import { Message } from '@lumino/messaging';
 import { Panel, TabBar, Widget } from '@lumino/widgets';
 
diff --git a/src/index.ts b/src/index.ts
index 7e072913..595ce525 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -187,9 +187,7 @@ async function activate(
     addCommands(
       app,
       gitExtension,
-      editorServices.factoryService.newInlineEditor.bind(
-        editorServices.factoryService
-      ),
+      editorServices.factoryService,
       languageRegistry,
       fileBrowser.model,
       settings,
diff --git a/src/model.ts b/src/model.ts
index e1bd8004..64f50cc2 100644
--- a/src/model.ts
+++ b/src/model.ts
@@ -1,6 +1,7 @@
 import { IChangedArgs, PathExt, URLExt } from '@jupyterlab/coreutils';
 import { IDocumentManager } from '@jupyterlab/docmanager';
 import { DocumentRegistry } from '@jupyterlab/docregistry';
+import { ServerConnection } from '@jupyterlab/services';
 import { ISettingRegistry } from '@jupyterlab/settingregistry';
 import { JSONExt, JSONObject } from '@lumino/coreutils';
 import { Poll } from '@lumino/polling';
@@ -866,15 +867,58 @@ export class GitExtension implements IGitExtension {
    * @throws {Git.NotInRepository} If the current path is not a Git repository
    * @throws {Git.GitResponseError} If the server response is not ok
    * @throws {ServerConnection.NetworkError} If the request cannot be made
+   * @throws {Git.HiddenFile} If the file is hidden
    */
   async ensureGitignore(): Promise<void> {
     const path = await this._getPathRepository();
 
     await requestAPI(URLExt.join(path, 'ignore'), 'POST', {});
+    try {
+      await this._docmanager?.services.contents.get(`${path}/.gitignore`, {
+        content: false
+      });
+    } catch (e) {
+      // If the previous request failed with a 404 error, it means hidden file cannot be accessed
+      if ((e as ServerConnection.ResponseError).response?.status === 404) {
+        throw new Git.HiddenFile();
+      }
+    }
     this._openGitignore();
     await this.refreshStatus();
   }
 
+  /**
+   * Reads content of .gitignore file
+   *
+   * @throws {Git.NotInRepository} If the current path is not a Git repository
+   * @throws {Git.GitResponseError} If the server response is not ok
+   * @throws {ServerConnection.NetworkError} If the request cannot be made
+   */
+  async readGitIgnore(): Promise<string> {
+    const path = await this._getPathRepository();
+
+    return (
+      (await requestAPI(URLExt.join(path, 'ignore'), 'GET')) as {
+        code: number;
+        content: string;
+      }
+    ).content;
+  }
+
+  /**
+   * Overwrites content onto .gitignore file
+   *
+   * @throws {Git.NotInRepository} If the current path is not a Git repository
+   * @throws {Git.GitResponseError} If the server response is not ok
+   * @throws {ServerConnection.NetworkError} If the request cannot be made
+   */
+  async writeGitIgnore(content: string): Promise<void> {
+    const path = await this._getPathRepository();
+
+    await requestAPI(URLExt.join(path, 'ignore'), 'POST', { content: content });
+    await this.refreshStatus();
+  }
+
   /**
    * Fetch to get ahead/behind status
    *
@@ -929,6 +973,7 @@ export class GitExtension implements IGitExtension {
    * @throws {Git.NotInRepository} If the current path is not a Git repository
    * @throws {Git.GitResponseError} If the server response is not ok
    * @throws {ServerConnection.NetworkError} If the request cannot be made
+   * @throws {Git.HiddenFile} If hidden files are not enabled
    */
   async ignore(filePath: string, useExtension: boolean): Promise<void> {
     const path = await this._getPathRepository();
@@ -937,7 +982,16 @@ export class GitExtension implements IGitExtension {
       file_path: filePath,
       use_extension: useExtension
     });
-
+    try {
+      await this._docmanager?.services.contents.get(`${path}/.gitignore`, {
+        content: false
+      });
+    } catch (e) {
+      // If the previous request failed with a 404 error, it means hidden file cannot be accessed
+      if ((e as ServerConnection.ResponseError).response?.status === 404) {
+        throw new Git.HiddenFile();
+      }
+    }
     this._openGitignore();
     await this.refreshStatus();
   }
diff --git a/src/style/ActionButtonStyle.ts b/src/style/ActionButtonStyle.ts
index e457370b..5248bc26 100644
--- a/src/style/ActionButtonStyle.ts
+++ b/src/style/ActionButtonStyle.ts
@@ -1,5 +1,5 @@
 import { style } from 'typestyle';
-import { NestedCSSProperties } from 'typestyle/lib/types';
+import type { NestedCSSProperties } from 'typestyle/lib/types';
 
 export const actionButtonStyle = style({
   flex: '0 0 auto',
diff --git a/src/style/FileItemStyle.ts b/src/style/FileItemStyle.ts
index 70190df5..81dc833d 100644
--- a/src/style/FileItemStyle.ts
+++ b/src/style/FileItemStyle.ts
@@ -1,5 +1,5 @@
 import { style } from 'typestyle';
-import { NestedCSSProperties } from 'typestyle/lib/types';
+import type { NestedCSSProperties } from 'typestyle/lib/types';
 import { actionButtonStyle, showButtonOnHover } from './ActionButtonStyle';
 
 export const fileStyle = style(
diff --git a/src/style/GitStageStyle.ts b/src/style/GitStageStyle.ts
index c9938dfb..dda7d27b 100644
--- a/src/style/GitStageStyle.ts
+++ b/src/style/GitStageStyle.ts
@@ -1,5 +1,5 @@
 import { style } from 'typestyle';
-import { NestedCSSProperties } from 'typestyle/lib/types';
+import type { NestedCSSProperties } from 'typestyle/lib/types';
 import { hiddenButtonStyle, showButtonOnHover } from './ActionButtonStyle';
 
 export const sectionAreaStyle = style(
diff --git a/src/tokens.ts b/src/tokens.ts
index 4a58281a..f9be80a1 100644
--- a/src/tokens.ts
+++ b/src/tokens.ts
@@ -1331,6 +1331,14 @@ export namespace Git {
     }
   }
 
+  export class HiddenFile extends Error {
+    constructor() {
+      super('File is hidden');
+      this.name = 'hiddenFile';
+      this.message = 'File is hidden and cannot be accessed.';
+    }
+  }
+
   /**
    * Interface for dialog with one checkbox.
    */
diff --git a/style/base.css b/style/base.css
index 19c03719..ca6ec35c 100644
--- a/style/base.css
+++ b/style/base.css
@@ -12,3 +12,11 @@
 .jp-git-tab-mod-preview {
   font-style: italic;
 }
+
+.not-saved > .lm-TabBar-tabCloseIcon > :not(:hover) > .jp-icon-busy[fill] {
+  fill: var(--jp-inverse-layout-color3);
+}
+
+.not-saved > .lm-TabBar-tabCloseIcon > :not(:hover) > .jp-icon3[fill] {
+  fill: none;
+}