diff --git a/README.md b/README.md
index 22d1874..aa1affc 100644
--- a/README.md
+++ b/README.md
@@ -36,7 +36,11 @@ Notely is built with Electron + React and is designed for project notes, meeting
- Open the current workspace folder in VS Code directly from **File -> Open Workspace in VS Code** (`Ctrl/Cmd + Shift + O`) on the landing screen.
- Reveal the workspace folder in the system File Explorer from **File -> Reveal Workspace in File Explorer** (`Ctrl/Cmd + Shift + J`) on the landing screen.
- Open the project website or the current note's website view from the **Web** menu (`Ctrl/Cmd + Shift + W`).
-- Compare note history versions and restore context from older revisions.
+- Track note history and changes with native **Git Version Control**.
+ - Interactive Git tab strip displaying Status, Commit History, Diff Comparison, Branch/Tag lists, Stash management, Syncing, and Config Settings.
+ - Side-by-side note differences comparing "Quick Notes" and "Formal Notes" sections independently.
+ - Toggle between rich visual **Markdown Preview Mode** (with correct pathing for local images/Excalidraw diagrams) and standard raw **Code View** for line differences.
+ - Word-level inline diff highlights, automatic legacy history-to-commit migrations on startup, and direct commit tagging.
- Preview Mermaid diagrams and rendered Markdown content.
- Visualize the workspace as an interactive note graph.
- Use built-in AI features powered by Gemini or Groq for chat, queries, and semantic search.
diff --git a/docs/feature-reference.md b/docs/feature-reference.md
index d073920..0fa11ef 100644
--- a/docs/feature-reference.md
+++ b/docs/feature-reference.md
@@ -242,15 +242,20 @@ Use All Tasks when you need both open and completed work in one place.
- Find tasks by keyword without opening individual notes
- Maintain accountability in team workspaces
-## 6. Version History and Recovery
+## 6. Git Version Control and History
-### Version snapshots
+### Native Git Repository
+Notely integrates native Git versioning to track your document history. Workspaces can be initialized as Git repositories directly from the Version Control panel.
-Notely keeps older copies of notes so you can recover changes.
+### Compare, Restore, and Tagging
+Open **Version Control -> History** (`Ctrl/Cmd + Shift + H`) or click the **History** button in the note top bar to:
+- Compare commits with word-level difference highlighting.
+- Toggle between rich **Markdown Preview Mode** and raw **Code View** for line differences.
+- Restore the active note to an older revision.
+- Add tag references to commits directly from the history timeline.
-### Compare and restore
-
-Open **File -> Versions** (`Ctrl/Cmd + Shift + H`) to compare current and previous versions, then restore when needed.
+### Branch, Stash, and Sync Management
+The full **Version Control Page** includes tabs to switch branches, create tags, push/pull changes to remote repositories, and stash unstaged changes to keep your workspace tidy.
## 7. Media Management
diff --git a/docs/top-tasks.md b/docs/top-tasks.md
index cd22625..caf95f4 100644
--- a/docs/top-tasks.md
+++ b/docs/top-tasks.md
@@ -75,9 +75,9 @@ Use this page when you want quick, direct steps.
## 12. View or restore note history
-1. Open **File -> Versions** (`Ctrl/Cmd + Shift + H`).
-2. Select a version.
-3. Compare or restore.
+1. Click the **History** button in the note's top-right toolbar or press `Ctrl/Cmd + Shift + H`.
+2. Select a commit from the history timeline sidebar.
+3. Compare revisions or click **Restore** to revert changes.
## 13. Search across all notes
diff --git a/docs/user-guide.md b/docs/user-guide.md
index 504db13..4acf318 100644
--- a/docs/user-guide.md
+++ b/docs/user-guide.md
@@ -38,14 +38,16 @@ Helpful actions:
- Format code blocks automatically and use the dedicated code editor popup.
- Fix issues shown by markdown validation and typo checks.
-## 4. Recover Changes with Version History
+## 4. Recover Changes with Git Version Control
-1. Open **File -> Versions** (`Ctrl/Cmd + Shift + H`).
-2. Select an older version.
-3. Compare it with your current note.
-4. Restore if needed.
+Notely tracks your document history with a native Git-backed system:
-Use this when content was accidentally changed or removed.
+1. Open **Version Control -> History** (`Ctrl/Cmd + Shift + H`) or click the **History** button in the top menu of an open note.
+2. Select a commit from the timeline list to inspect its details.
+3. Compare commits or restore a note to that version.
+4. Add tags directly to commits to bookmark key milestones.
+
+Use this to track changes chronologically and recover older revisions of any note.
## 5. Work with Images and Files
diff --git a/electron/lib/core/appMenu.cjs b/electron/lib/core/appMenu.cjs
index a8bc290..964e5b7 100644
--- a/electron/lib/core/appMenu.cjs
+++ b/electron/lib/core/appMenu.cjs
@@ -92,11 +92,6 @@ function buildAppMenu(win, context = {}) {
accelerator: "CmdOrCtrl+Shift+E",
click: () => sendMenuAction(win, "export-pdf")
},
- {
- label: "Versions",
- accelerator: "CmdOrCtrl+Shift+H",
- click: () => sendMenuAction(win, "manage-versions")
- },
{ type: "separator" },
{
label: "Rename Note",
@@ -478,6 +473,63 @@ function buildAppMenu(win, context = {}) {
}
]
},
+ {
+ label: "Version Control",
+ submenu: [
+ {
+ label: "Open Version Control",
+ accelerator: "CmdOrCtrl+Shift+G",
+ click: () => sendMenuAction(win, "open-git-version-control")
+ },
+ { type: "separator" },
+ {
+ label: "Commit\u2026",
+ accelerator: "CmdOrCtrl+Shift+K",
+ click: () => sendMenuAction(win, "git-commit")
+ },
+ {
+ label: "History",
+ accelerator: "CmdOrCtrl+Shift+H",
+ enabled: screen === "document",
+ click: () => sendMenuAction(win, "git-history")
+ },
+ {
+ label: "Diff Current Note",
+ enabled: screen === "document",
+ click: () => sendMenuAction(win, "git-diff-current")
+ },
+ {
+ label: "Compare Versions",
+ click: () => sendMenuAction(win, "git-compare")
+ },
+ { type: "separator" },
+ {
+ label: "Push",
+ accelerator: "CmdOrCtrl+Shift+U",
+ click: () => sendMenuAction(win, "git-push")
+ },
+ {
+ label: "Pull",
+ click: () => sendMenuAction(win, "git-pull")
+ },
+ {
+ label: "Fetch",
+ click: () => sendMenuAction(win, "git-fetch")
+ },
+ {
+ label: "Sync (Pull then Push)",
+ click: () => sendMenuAction(win, "git-sync")
+ },
+ { type: "separator" },
+ {
+ label: "Ignore App Data in Git",
+ type: "checkbox",
+ checked: Boolean(context?.autoIgnoreMetadataInGit !== false),
+ click: () => sendMenuAction(win, "toggle-auto-ignore-git-metadata")
+ },
+
+ ]
+ },
{
label: "P2P",
submenu: [
diff --git a/electron/lib/documents/documentIpc.cjs b/electron/lib/documents/documentIpc.cjs
index f5c8e75..3b8a3ad 100644
--- a/electron/lib/documents/documentIpc.cjs
+++ b/electron/lib/documents/documentIpc.cjs
@@ -347,59 +347,12 @@ function registerDocumentIpcHandlers(ipcMain, deps) {
})
});
- if (!isAutoSave) {
- const previousHash = hashContent(previous);
- if (!hasMatchingFileBackedVersion(resolved, previousHash)) {
- const versionPath = createVersionSnapshot(resolved, previous, saveReason);
-
- resolveMetadataStore().addHistory({
- filePath: resolved,
- versionPath,
- fileHash: previousHash,
- reason: saveReason,
- createdAt: new Date().toISOString()
- });
- }
- }
-
const parsed = parseDocument(next, resolved);
dashboardCache?.recordSave?.(parsed);
return parsed;
});
- registerTrustedHandler("documents:history", (_event, filePath) => {
- const resolved = path.resolve(filePath);
- return resolveMetadataStore().getHistory(resolved);
- });
- registerTrustedHandler("documents:restore", (_event, payload) => {
- const notesRoot = getNotesRoot();
- const versionsRoot = getVersionsRoot();
- const resolved = path.resolve(payload.filePath);
- const versionPath = path.resolve(payload.versionPath);
- if (!filePathWithin(notesRoot, resolved) || !filePathWithin(versionsRoot, versionPath)) {
- throw new Error("Invalid restore path.");
- }
-
- const current = fs.readFileSync(resolved, "utf8");
- const rollbackDir = path.join(versionsRoot, slugify(path.basename(resolved)));
- ensureDir(rollbackDir);
- const rollbackPath = path.join(rollbackDir, `${nowStamp()}-before-restore.md`);
- fs.writeFileSync(rollbackPath, current, "utf8");
-
- const restored = fs.readFileSync(versionPath, "utf8");
- fs.writeFileSync(resolved, restored, "utf8");
-
- resolveMetadataStore().addHistory({
- filePath: resolved,
- versionPath: rollbackPath,
- fileHash: hashContent(current),
- reason: "before-restore",
- createdAt: new Date().toISOString()
- });
-
- return parseDocument(restored, resolved);
- });
registerTrustedHandler("documents:open-in-editor", async (_event, filePath) => {
const notesRoot = getNotesRoot();
@@ -546,44 +499,7 @@ function registerDocumentIpcHandlers(ipcMain, deps) {
}
});
- registerTrustedHandler("documents:read-version", (_event, payload) => {
- const notesRoot = getNotesRoot();
- const versionsRoot = getVersionsRoot();
- const resolvedFilePath = path.resolve(payload?.filePath || "");
- const resolvedVersionPath = path.resolve(payload?.versionPath || "");
- if (!filePathWithin(notesRoot, resolvedFilePath) || path.extname(resolvedFilePath).toLowerCase() !== ".md") {
- throw new Error("Invalid document path.");
- }
- if (!filePathWithin(versionsRoot, resolvedVersionPath) || path.extname(resolvedVersionPath).toLowerCase() !== ".md") {
- throw new Error("Invalid version path.");
- }
- if (!fs.existsSync(resolvedVersionPath)) {
- throw new Error("Version file does not exist.");
- }
-
- return fs.readFileSync(resolvedVersionPath, "utf8");
- });
-
- registerTrustedHandler("documents:delete-version", (_event, payload) => {
- const notesRoot = getNotesRoot();
- const versionsRoot = getVersionsRoot();
- const resolvedFilePath = path.resolve(payload?.filePath || "");
- const resolvedVersionPath = path.resolve(payload?.versionPath || "");
-
- if (!filePathWithin(notesRoot, resolvedFilePath) || path.extname(resolvedFilePath).toLowerCase() !== ".md") {
- throw new Error("Invalid document path.");
- }
- if (!filePathWithin(versionsRoot, resolvedVersionPath) || path.extname(resolvedVersionPath).toLowerCase() !== ".md") {
- throw new Error("Invalid version path.");
- }
-
- if (fs.existsSync(resolvedVersionPath)) {
- fs.unlinkSync(resolvedVersionPath);
- }
- metadataStore.deleteHistoryVersion(resolvedFilePath, resolvedVersionPath);
- return true;
- });
registerTrustedHandler("workspace:graph-data", () => {
const activeProject = getActiveProject();
diff --git a/electron/lib/git/gitIpc.cjs b/electron/lib/git/gitIpc.cjs
new file mode 100644
index 0000000..574e89c
--- /dev/null
+++ b/electron/lib/git/gitIpc.cjs
@@ -0,0 +1,285 @@
+/**
+ * gitIpc.cjs — Registers all git:* IPC handlers.
+ *
+ * Pattern mirrors documentIpc.cjs: each handler validates the sender,
+ * calls gitService, and returns { ok, data/error }.
+ */
+
+const gitService = require("./gitService.cjs");
+
+/**
+ * @param {Electron.IpcMain} ipcMain
+ * @param {{ assertTrustedIpcSender: Function, BrowserWindow: any, getNotesRoot: Function }} deps
+ */
+function registerGitIpcHandlers(ipcMain, deps) {
+ const { assertTrustedIpcSender, BrowserWindow, getNotesRoot } = deps;
+
+ function handle(channel, fn) {
+ ipcMain.handle(channel, async (event, ...args) => {
+ assertTrustedIpcSender(BrowserWindow, event, channel);
+ try {
+ return await fn(event, ...args);
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
+ }
+ });
+ }
+
+ // ── Detection & Repo Info ──────────────────────────────────────────────────
+
+ handle("git:detect", async () => {
+ return gitService.detectGit();
+ });
+
+ handle("git:get-repo-info", async (_event, { workspacePath } = {}) => {
+ const root = workspacePath || getNotesRoot();
+ if (!root) return { ok: false, error: "No workspace path." };
+ return gitService.getRepoInfo(root);
+ });
+
+ handle("git:init-repo", async (_event, { workspacePath } = {}) => {
+ const root = workspacePath || getNotesRoot();
+ if (!root) return { ok: false, error: "No workspace path." };
+ return gitService.initRepo(root);
+ });
+
+ // ── Status ─────────────────────────────────────────────────────────────────
+
+ handle("git:get-status", async (_event, { workspacePath } = {}) => {
+ const root = workspacePath || getNotesRoot();
+ if (!root) return { ok: false, error: "No workspace path." };
+ return gitService.getStatus(root);
+ });
+
+ // ── Log ────────────────────────────────────────────────────────────────────
+
+ handle("git:get-log", async (_event, { workspacePath, filePath, limit, skip, branch } = {}) => {
+ const root = workspacePath || getNotesRoot();
+ if (!root) return { ok: false, error: "No workspace path." };
+ return gitService.getLog(root, { filePath, limit, skip, branch });
+ });
+
+ handle("git:get-commit-files", async (_event, { workspacePath, commitHash } = {}) => {
+ const root = workspacePath || getNotesRoot();
+ if (!root) return { ok: false, error: "No workspace path." };
+ if (!commitHash) return { ok: false, error: "commitHash required." };
+ return gitService.getCommitFiles(root, commitHash);
+ });
+
+ handle("git:get-file-at-commit", async (_event, { workspacePath, commitHash, filePath } = {}) => {
+ const root = workspacePath || getNotesRoot();
+ if (!root) return { ok: false, error: "No workspace path." };
+ if (!commitHash) return { ok: false, error: "commitHash required." };
+ if (!filePath) return { ok: false, error: "filePath required." };
+ return gitService.getFileAtCommit(root, commitHash, filePath);
+ });
+
+ handle("git:get-file-diff", async (_event, { workspacePath, fromHash, toHash, filePath } = {}) => {
+ const root = workspacePath || getNotesRoot();
+ if (!root) return { ok: false, error: "No workspace path." };
+ if (!fromHash) return { ok: false, error: "fromHash required." };
+ if (!toHash) return { ok: false, error: "toHash required." };
+ return gitService.getFileDiff(root, fromHash, toHash, filePath || null);
+ });
+
+ // ── Commit (user-triggered only) ───────────────────────────────────────────
+
+ handle("git:commit", async (_event, { workspacePath, message, filePaths } = {}) => {
+ const root = workspacePath || getNotesRoot();
+ if (!root) return { ok: false, error: "No workspace path." };
+ if (!message) return { ok: false, error: "Commit message required." };
+ return gitService.commit(root, { message, filePaths });
+ });
+
+ handle("git:restore-file-at-commit", async (_event, { workspacePath, commitHash, filePath } = {}) => {
+ const root = workspacePath || getNotesRoot();
+ if (!root) return { ok: false, error: "No workspace path." };
+ if (!commitHash) return { ok: false, error: "commitHash required." };
+ if (!filePath) return { ok: false, error: "filePath required." };
+ return gitService.restoreFileAtCommit(root, commitHash, filePath);
+ });
+
+ // ── Branches ───────────────────────────────────────────────────────────────
+
+ handle("git:list-branches", async (_event, { workspacePath } = {}) => {
+ const root = workspacePath || getNotesRoot();
+ if (!root) return { ok: false, error: "No workspace path." };
+ return gitService.listBranches(root);
+ });
+
+ handle("git:create-branch", async (_event, { workspacePath, name, from } = {}) => {
+ const root = workspacePath || getNotesRoot();
+ if (!root) return { ok: false, error: "No workspace path." };
+ if (!name) return { ok: false, error: "Branch name required." };
+ return gitService.createBranch(root, name, from || null);
+ });
+
+ handle("git:rename-branch", async (_event, { workspacePath, oldName, newName } = {}) => {
+ const root = workspacePath || getNotesRoot();
+ if (!root) return { ok: false, error: "No workspace path." };
+ if (!oldName || !newName) return { ok: false, error: "oldName and newName required." };
+ return gitService.renameBranch(root, oldName, newName);
+ });
+
+ handle("git:delete-branch", async (_event, { workspacePath, name, force } = {}) => {
+ const root = workspacePath || getNotesRoot();
+ if (!root) return { ok: false, error: "No workspace path." };
+ if (!name) return { ok: false, error: "Branch name required." };
+ return gitService.deleteBranch(root, name, force === true);
+ });
+
+ handle("git:switch-branch", async (_event, { workspacePath, name } = {}) => {
+ const root = workspacePath || getNotesRoot();
+ if (!root) return { ok: false, error: "No workspace path." };
+ if (!name) return { ok: false, error: "Branch name required." };
+ return gitService.switchBranch(root, name);
+ });
+
+ handle("git:merge-branch", async (_event, { workspacePath, from } = {}) => {
+ const root = workspacePath || getNotesRoot();
+ if (!root) return { ok: false, error: "No workspace path." };
+ if (!from) return { ok: false, error: "Source branch required." };
+ return gitService.mergeBranch(root, from);
+ });
+
+ // ── Tags ───────────────────────────────────────────────────────────────────
+
+ handle("git:list-tags", async (_event, { workspacePath } = {}) => {
+ const root = workspacePath || getNotesRoot();
+ if (!root) return { ok: false, error: "No workspace path." };
+ return gitService.listTags(root);
+ });
+
+ handle("git:create-tag", async (_event, { workspacePath, name, commitHash, message } = {}) => {
+ const root = workspacePath || getNotesRoot();
+ if (!root) return { ok: false, error: "No workspace path." };
+ if (!name) return { ok: false, error: "Tag name required." };
+ return gitService.createTag(root, { name, commitHash, message });
+ });
+
+ handle("git:delete-tag", async (_event, { workspacePath, name } = {}) => {
+ const root = workspacePath || getNotesRoot();
+ if (!root) return { ok: false, error: "No workspace path." };
+ if (!name) return { ok: false, error: "Tag name required." };
+ return gitService.deleteTag(root, name);
+ });
+
+ // ── Stash ──────────────────────────────────────────────────────────────────
+
+ handle("git:stash-list", async (_event, { workspacePath } = {}) => {
+ const root = workspacePath || getNotesRoot();
+ if (!root) return { ok: false, error: "No workspace path." };
+ return gitService.stashList(root);
+ });
+
+ handle("git:stash-push", async (_event, { workspacePath, message } = {}) => {
+ const root = workspacePath || getNotesRoot();
+ if (!root) return { ok: false, error: "No workspace path." };
+ return gitService.stashPush(root, message || null);
+ });
+
+ handle("git:stash-pop", async (_event, { workspacePath, index } = {}) => {
+ const root = workspacePath || getNotesRoot();
+ if (!root) return { ok: false, error: "No workspace path." };
+ return gitService.stashPop(root, index != null ? Number(index) : null);
+ });
+
+ handle("git:stash-drop", async (_event, { workspacePath, index } = {}) => {
+ const root = workspacePath || getNotesRoot();
+ if (!root) return { ok: false, error: "No workspace path." };
+ if (index == null) return { ok: false, error: "Stash index required." };
+ return gitService.stashDrop(root, Number(index));
+ });
+
+ // ── Remotes ────────────────────────────────────────────────────────────────
+
+ handle("git:list-remotes", async (_event, { workspacePath } = {}) => {
+ const root = workspacePath || getNotesRoot();
+ if (!root) return { ok: false, error: "No workspace path." };
+ return gitService.listRemotes(root);
+ });
+
+ handle("git:add-remote", async (_event, { workspacePath, name, url } = {}) => {
+ const root = workspacePath || getNotesRoot();
+ if (!root) return { ok: false, error: "No workspace path." };
+ if (!name) return { ok: false, error: "Remote name required." };
+ if (!url) return { ok: false, error: "Remote URL required." };
+ return gitService.addRemote(root, name, url);
+ });
+
+ handle("git:remove-remote", async (_event, { workspacePath, name } = {}) => {
+ const root = workspacePath || getNotesRoot();
+ if (!root) return { ok: false, error: "No workspace path." };
+ if (!name) return { ok: false, error: "Remote name required." };
+ return gitService.removeRemote(root, name);
+ });
+
+ handle("git:push", async (_event, { workspacePath, remote, branch, auth } = {}) => {
+ const root = workspacePath || getNotesRoot();
+ if (!root) return { ok: false, error: "No workspace path." };
+ return gitService.push(root, { remote, branch, auth });
+ });
+
+ handle("git:pull", async (_event, { workspacePath, remote, branch, auth } = {}) => {
+ const root = workspacePath || getNotesRoot();
+ if (!root) return { ok: false, error: "No workspace path." };
+ return gitService.pull(root, { remote, branch, auth });
+ });
+
+ handle("git:fetch", async (_event, { workspacePath, remote, auth } = {}) => {
+ const root = workspacePath || getNotesRoot();
+ if (!root) return { ok: false, error: "No workspace path." };
+ return gitService.fetch(root, { remote, auth });
+ });
+
+ // ── Search ─────────────────────────────────────────────────────────────────
+
+ handle("git:search", async (_event, { workspacePath, query, type } = {}) => {
+ const root = workspacePath || getNotesRoot();
+ if (!root) return { ok: false, error: "No workspace path." };
+ return gitService.search(root, { query, type });
+ });
+
+ // ── Extras ─────────────────────────────────────────────────────────────────
+
+ handle("git:get-deleted-files", async (_event, { workspacePath } = {}) => {
+ const root = workspacePath || getNotesRoot();
+ if (!root) return { ok: false, error: "No workspace path." };
+ return gitService.getDeletedFiles(root);
+ });
+
+ handle("git:get-workspace-stats", async (_event, { workspacePath } = {}) => {
+ const root = workspacePath || getNotesRoot();
+ if (!root) return { ok: false, error: "No workspace path." };
+ return gitService.getWorkspaceStats(root);
+ });
+
+ // ── Migration ──────────────────────────────────────────────────────────────
+
+ handle("git:migrate-legacy", async (_event, { workspacePath } = {}) => {
+ const root = workspacePath || getNotesRoot();
+ if (!root) return { ok: false, error: "No workspace path." };
+ // metadataStore not accessible here — migration is triggered from main.cjs
+ return gitService.migrateFromLegacy(root, null);
+ });
+
+ // ── Gitignore ──────────────────────────────────────────────────────────────
+
+ handle("git:ensure-managed-gitignore", async (_event, { workspacePath } = {}) => {
+ const root = workspacePath || getNotesRoot();
+ if (!root) return { ok: false, error: "No workspace path." };
+ const repoRoot = await gitService.findRepoRoot(root);
+ if (!repoRoot) return { ok: false, error: "Not a git repository." };
+ return gitService.ensureManagedGitignoreBlock(repoRoot);
+ });
+
+ handle("git:remove-managed-gitignore", async (_event, { workspacePath } = {}) => {
+ const root = workspacePath || getNotesRoot();
+ if (!root) return { ok: false, error: "No workspace path." };
+ const repoRoot = await gitService.findRepoRoot(root);
+ if (!repoRoot) return { ok: false, error: "Not a git repository." };
+ return gitService.removeManagedGitignoreBlock(repoRoot);
+ });
+}
+
+module.exports = { registerGitIpcHandlers };
diff --git a/electron/lib/git/gitService.cjs b/electron/lib/git/gitService.cjs
new file mode 100644
index 0000000..63fec9b
--- /dev/null
+++ b/electron/lib/git/gitService.cjs
@@ -0,0 +1,1236 @@
+/**
+ * gitService.cjs — Git operations for Notely via simple-git.
+ *
+ * All exported functions are async and return { ok: true, data } or { ok: false, error: string }.
+ * They never throw — callers receive structured results.
+ *
+ * Git binary must be on PATH. Use detectGit() before any operation to check availability.
+ */
+
+const path = require("node:path");
+const fs = require("node:fs");
+const os = require("node:os");
+
+// simple-git is imported lazily so the app still starts if it somehow isn't installed.
+function getSimpleGit() {
+ return require("simple-git");
+}
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+function ok(data) {
+ return { ok: true, data };
+}
+
+function fail(error) {
+ const message = error instanceof Error ? error.message : String(error || "Unknown error");
+ return { ok: false, error: message };
+}
+
+function git(workspacePath, options = {}) {
+ const simpleGit = getSimpleGit();
+ return simpleGit(workspacePath, {
+ binary: "git",
+ maxConcurrentProcesses: 4,
+ trimmed: true,
+ ...options,
+ });
+}
+
+function ensureDir(dirPath) {
+ fs.mkdirSync(dirPath, { recursive: true });
+}
+
+function nowIso() {
+ return new Date().toISOString();
+}
+
+function shortHash(hash) {
+ return String(hash || "").slice(0, 7);
+}
+
+// ─── Detection ───────────────────────────────────────────────────────────────
+
+/**
+ * Check if git binary is available on PATH.
+ * @returns {{ ok: true, data: { available: true, version: string } } | { ok: true, data: { available: false } }}
+ */
+async function detectGit() {
+ try {
+ const simpleGit = getSimpleGit();
+ // Use a temp dir — we only care whether git is on PATH
+ const tmpDir = os.tmpdir();
+ const version = await simpleGit(tmpDir).raw(["--version"]);
+ return ok({ available: true, version: String(version || "").trim() });
+ } catch {
+ return ok({ available: false, version: "" });
+ }
+}
+
+/**
+ * Check whether a directory is (or is inside) a git repository.
+ * Returns the repository root path, or null if not in a repo.
+ */
+async function findRepoRoot(workspacePath) {
+ try {
+ const root = await git(workspacePath).revparse(["--show-toplevel"]);
+ return String(root || "").trim() || null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * @returns {{ ok: true, data: { isRepo: bool, repoRoot: string|null } }}
+ */
+async function getRepoInfo(workspacePath) {
+ try {
+ const repoRoot = await findRepoRoot(workspacePath);
+ return ok({ isRepo: repoRoot !== null, repoRoot });
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+// ─── Repository Init ──────────────────────────────────────────────────────────
+
+/**
+ * Initialize a new git repository in workspacePath.
+ * Creates README.md, .gitignore, and an initial commit if the directory
+ * is not already a repo.
+ */
+async function initRepo(workspacePath) {
+ try {
+ const g = git(workspacePath);
+
+ // Already a repo?
+ const repoRoot = await findRepoRoot(workspacePath);
+ if (repoRoot) {
+ return ok({ alreadyInitialized: true, repoRoot });
+ }
+
+ await g.init();
+
+ // README
+ const readmePath = path.join(workspacePath, "README.md");
+ if (!fs.existsSync(readmePath)) {
+ const name = path.basename(workspacePath);
+ fs.writeFileSync(
+ readmePath,
+ `# ${name}\n\nNotes workspace managed by [Notely](https://github.com/WGLabz/notely).\n`,
+ "utf8"
+ );
+ }
+
+ // .gitignore
+ const gitignorePath = path.join(workspacePath, ".gitignore");
+ if (!fs.existsSync(gitignorePath)) {
+ fs.writeFileSync(
+ gitignorePath,
+ "# Notes app internal data\n.notes-app/\n\n# OS\n.DS_Store\nThumbs.db\n",
+ "utf8"
+ );
+ }
+
+ // Set default branch to main
+ try {
+ await g.raw(["symbolic-ref", "HEAD", "refs/heads/main"]);
+ } catch {
+ // Git version may not support this; fall through
+ }
+
+ // Stage everything and initial commit
+ await g.add(".");
+ await g.commit("Initial commit", { "--allow-empty": null });
+
+ const repoRootResult = await findRepoRoot(workspacePath);
+ return ok({ alreadyInitialized: false, repoRoot: repoRootResult || workspacePath });
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+// ─── Status ───────────────────────────────────────────────────────────────────
+
+/**
+ * Get the working tree status.
+ * @returns {{ ok: true, data: { branch, upstream, ahead, behind, files[] } }}
+ */
+async function getStatus(workspacePath) {
+ try {
+ const repoRoot = await findRepoRoot(workspacePath);
+ if (!repoRoot) return fail("Not a git repository.");
+
+ const g = git(repoRoot);
+ const status = await g.status();
+
+ const files = [
+ ...status.modified.map((p) => ({ path: p, status: "modified" })),
+ ...status.created.map((p) => ({ path: p, status: "added" })),
+ ...status.deleted.map((p) => ({ path: p, status: "deleted" })),
+ ...status.renamed.map((r) => ({
+ path: typeof r === "string" ? r : (r.to || r),
+ from: typeof r === "object" ? r.from : null,
+ status: "renamed",
+ })),
+ ...status.not_added.map((p) => ({ path: p, status: "untracked" })),
+ ...status.conflicted.map((p) => ({ path: p, status: "conflicted" })),
+ ...status.staged.filter(
+ (p) => !status.modified.includes(p) && !status.created.includes(p)
+ ).map((p) => ({ path: p, status: "staged" })),
+ ];
+
+ return ok({
+ branch: status.current || "",
+ upstream: status.tracking || "",
+ ahead: Number(status.ahead) || 0,
+ behind: Number(status.behind) || 0,
+ files,
+ isClean: status.isClean(),
+ repoRoot,
+ });
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+// ─── Log ─────────────────────────────────────────────────────────────────────
+
+const LOG_FORMAT = {
+ hash: "%H",
+ shortHash: "%h",
+ message: "%s",
+ body: "%b",
+ authorName: "%an",
+ authorEmail: "%ae",
+ date: "%aI", // ISO 8601
+ refs: "%D",
+};
+
+/**
+ * Get commit log.
+ * @param {string} workspacePath
+ * @param {{ filePath?: string, limit?: number, skip?: number, branch?: string }} options
+ */
+async function getLog(workspacePath, options = {}) {
+ try {
+ const repoRoot = await findRepoRoot(workspacePath);
+ if (!repoRoot) return fail("Not a git repository.");
+
+ const g = git(repoRoot);
+ const { filePath, limit = 100, skip = 0, branch } = options;
+
+ const logOptions = {
+ format: LOG_FORMAT,
+ maxCount: Math.min(Number(limit) || 100, 500),
+ "--skip": String(Number(skip) || 0),
+ };
+
+ if (branch) logOptions[branch] = null;
+
+ let logResult;
+ if (filePath) {
+ const relPath = path.relative(repoRoot, path.resolve(filePath));
+ logResult = await g.log({ ...logOptions, file: relPath });
+ } else {
+ logResult = await g.log(logOptions);
+ }
+
+ const commits = (logResult.all || []).map((c) => ({
+ hash: c.hash,
+ shortHash: c.shortHash || shortHash(c.hash),
+ message: String(c.message || "").trim(),
+ body: String(c.body || "").trim(),
+ author: String(c.authorName || "").trim(),
+ authorEmail: String(c.authorEmail || "").trim(),
+ date: c.date,
+ refs: String(c.refs || "").trim(),
+ tags: String(c.refs || "").split(",").map((r) => r.trim()).filter((r) => r.startsWith("tag: ")).map((r) => r.slice(5)),
+ branches: String(c.refs || "").split(",").map((r) => r.trim()).filter((r) => r && !r.startsWith("tag: ") && !r.startsWith("HEAD")),
+ }));
+
+ return ok(commits);
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+/**
+ * Get which files changed in a specific commit.
+ */
+async function getCommitFiles(workspacePath, commitHash) {
+ try {
+ const repoRoot = await findRepoRoot(workspacePath);
+ if (!repoRoot) return fail("Not a git repository.");
+
+ const g = git(repoRoot);
+ const output = await g.raw([
+ "diff-tree",
+ "--no-commit-id",
+ "-r",
+ "--name-status",
+ commitHash,
+ ]);
+
+ const files = String(output || "")
+ .split("\n")
+ .filter(Boolean)
+ .map((line) => {
+ const [status, ...rest] = line.split("\t");
+ return { status: String(status || "").trim(), path: String(rest[rest.length - 1] || "").trim() };
+ });
+
+ return ok(files);
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+// ─── File at Commit ───────────────────────────────────────────────────────────
+
+/**
+ * Get the content of a file at a specific commit.
+ */
+async function getFileAtCommit(workspacePath, commitHash, filePath) {
+ try {
+ const repoRoot = await findRepoRoot(workspacePath);
+ if (!repoRoot) return fail("Not a git repository.");
+
+ const resolvedPath = path.resolve(repoRoot, filePath);
+ const relPath = path.relative(repoRoot, resolvedPath).replace(/\\/g, "/");
+ if (relPath.startsWith("..") || path.isAbsolute(relPath)) {
+ return fail(`File '${path.basename(filePath)}' is outside the repository root at '${repoRoot}'.`);
+ }
+
+ const g = git(repoRoot);
+ if (commitHash === "WORKING" || !commitHash) {
+ if (fs.existsSync(resolvedPath)) {
+ const content = fs.readFileSync(resolvedPath, "utf8");
+ return ok(String(content || ""));
+ }
+ return ok("");
+ }
+ const content = await g.show([`${commitHash}:${relPath}`]);
+ return ok(String(content || ""));
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+// ─── Diff ─────────────────────────────────────────────────────────────────────
+
+/**
+ * Get unified diff between two commits (or commit vs working tree).
+ * @param {string} workspacePath
+ * @param {string} fromHash - "HEAD" | commit hash | branch name
+ * @param {string} toHash - "WORKING" for working tree | commit hash | branch name
+ * @param {string|null} filePath - optional file filter
+ */
+async function getFileDiff(workspacePath, fromHash, toHash, filePath = null) {
+ try {
+ const repoRoot = await findRepoRoot(workspacePath);
+ if (!repoRoot) return fail("Not a git repository.");
+
+ const g = git(repoRoot);
+ const args = ["diff", "--unified=3"];
+
+ if (toHash === "WORKING") {
+ args.push(fromHash);
+ } else {
+ args.push(`${fromHash}..${toHash}`);
+ }
+
+ if (filePath) {
+ const relPath = path.relative(repoRoot, path.resolve(repoRoot, filePath)).replace(/\\/g, "/");
+ args.push("--", relPath);
+ }
+
+ const diff = await g.raw(args);
+ return ok(String(diff || ""));
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+// ─── Commit (user-triggered) ──────────────────────────────────────────────────
+
+/**
+ * Stage specified files (or all changes) and create a commit.
+ * This is ALWAYS user-triggered. Never called automatically.
+ *
+ * @param {string} workspacePath
+ * @param {{ message: string, filePaths?: string[] }} options
+ */
+async function commit(workspacePath, options) {
+ try {
+ const repoRoot = await findRepoRoot(workspacePath);
+ if (!repoRoot) return fail("Not a git repository.");
+
+ const message = String(options?.message || "").trim();
+ if (!message) return fail("Commit message is required.");
+
+ const g = git(repoRoot);
+
+ const filePaths = Array.isArray(options?.filePaths) && options.filePaths.length > 0
+ ? options.filePaths.map((fp) => path.relative(repoRoot, path.resolve(repoRoot, fp)).replace(/\\/g, "/"))
+ : ["."];
+
+ for (const fp of filePaths) {
+ await g.add(fp);
+ }
+
+ const result = await g.commit(message);
+
+ return ok({
+ hash: result.commit || "",
+ branch: result.branch || "",
+ summary: result.summary || {},
+ });
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+// ─── Restore ─────────────────────────────────────────────────────────────────
+
+/**
+ * Restore a single file to its state at a given commit.
+ * Creates a new commit so history is NEVER rewritten.
+ *
+ * @param {string} workspacePath
+ * @param {string} commitHash
+ * @param {string} filePath
+ */
+async function restoreFileAtCommit(workspacePath, commitHash, filePath) {
+ try {
+ const repoRoot = await findRepoRoot(workspacePath);
+ if (!repoRoot) return fail("Not a git repository.");
+
+ const g = git(repoRoot);
+ const relPath = path.relative(repoRoot, path.resolve(repoRoot, filePath)).replace(/\\/g, "/");
+
+ // Checkout the file at the given commit into the working tree
+ await g.raw(["checkout", commitHash, "--", relPath]);
+
+ // Immediately commit the restore (never rewrite history)
+ const fileName = path.basename(filePath);
+ const shortCommit = shortHash(commitHash);
+ const message = `Restored ${fileName} to ${shortCommit}`;
+
+ await g.add(relPath);
+ const result = await g.commit(message);
+
+ return ok({
+ hash: result.commit || "",
+ message,
+ restoredFrom: commitHash,
+ filePath,
+ });
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+// ─── Branches ─────────────────────────────────────────────────────────────────
+
+async function listBranches(workspacePath) {
+ try {
+ const repoRoot = await findRepoRoot(workspacePath);
+ if (!repoRoot) return fail("Not a git repository.");
+
+ const result = await git(repoRoot).branchLocal();
+
+ const branches = (result.all || []).map((name) => {
+ return {
+ name,
+ current: name === result.current,
+ remote: false,
+ };
+ });
+
+ return ok({ branches, current: result.current });
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+async function createBranch(workspacePath, name, from = null) {
+ try {
+ const repoRoot = await findRepoRoot(workspacePath);
+ if (!repoRoot) return fail("Not a git repository.");
+
+ const g = git(repoRoot);
+ if (from) {
+ await g.checkoutBranch(name, from);
+ } else {
+ await g.checkoutLocalBranch(name);
+ }
+ return ok({ name });
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+async function renameBranch(workspacePath, oldName, newName) {
+ try {
+ const repoRoot = await findRepoRoot(workspacePath);
+ if (!repoRoot) return fail("Not a git repository.");
+
+ await git(repoRoot).raw(["branch", "-m", oldName, newName]);
+ return ok({ oldName, newName });
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+async function deleteBranch(workspacePath, name, force = false) {
+ try {
+ const repoRoot = await findRepoRoot(workspacePath);
+ if (!repoRoot) return fail("Not a git repository.");
+
+ const flag = force ? "-D" : "-d";
+ await git(repoRoot).raw(["branch", flag, name]);
+ return ok({ name });
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+async function switchBranch(workspacePath, name) {
+ try {
+ const repoRoot = await findRepoRoot(workspacePath);
+ if (!repoRoot) return fail("Not a git repository.");
+
+ await git(repoRoot).checkout(name);
+ return ok({ name });
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+async function mergeBranch(workspacePath, from) {
+ try {
+ const repoRoot = await findRepoRoot(workspacePath);
+ if (!repoRoot) return fail("Not a git repository.");
+
+ const result = await git(repoRoot).merge([from]);
+ return ok({ result: String(result || "") });
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+// ─── Tags ─────────────────────────────────────────────────────────────────────
+
+async function listTags(workspacePath) {
+ try {
+ const repoRoot = await findRepoRoot(workspacePath);
+ if (!repoRoot) return fail("Not a git repository.");
+
+ const output = await git(repoRoot).raw([
+ "tag",
+ "-l",
+ "--format=%(refname:short)\t%(objectname:short)\t%(*objectname:short)\t%(creatordate:iso)\t%(subject)",
+ ]);
+
+ const tags = String(output || "")
+ .split("\n")
+ .filter(Boolean)
+ .map((line) => {
+ const [name, hash, taggedHash, date, ...msgParts] = line.split("\t");
+ return {
+ name: String(name || "").trim(),
+ hash: String(taggedHash || hash || "").trim(),
+ date: String(date || "").trim(),
+ message: msgParts.join("\t").trim(),
+ };
+ });
+
+ return ok(tags);
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+async function createTag(workspacePath, options) {
+ try {
+ const repoRoot = await findRepoRoot(workspacePath);
+ if (!repoRoot) return fail("Not a git repository.");
+
+ const { name, commitHash, message } = options;
+ if (!name) return fail("Tag name is required.");
+
+ const g = git(repoRoot);
+ const args = ["tag"];
+
+ if (message) {
+ args.push("-a", name, "-m", message);
+ } else {
+ args.push(name);
+ }
+
+ if (commitHash) args.push(commitHash);
+
+ await g.raw(args);
+ return ok({ name });
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+async function deleteTag(workspacePath, name) {
+ try {
+ const repoRoot = await findRepoRoot(workspacePath);
+ if (!repoRoot) return fail("Not a git repository.");
+
+ await git(repoRoot).raw(["tag", "-d", name]);
+ return ok({ name });
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+// ─── Stash ────────────────────────────────────────────────────────────────────
+
+async function stashList(workspacePath) {
+ try {
+ const repoRoot = await findRepoRoot(workspacePath);
+ if (!repoRoot) return fail("Not a git repository.");
+
+ const output = await git(repoRoot).raw([
+ "stash",
+ "list",
+ "--format=%gd\t%s\t%aI",
+ ]);
+
+ const stashes = String(output || "")
+ .split("\n")
+ .filter(Boolean)
+ .map((line, index) => {
+ const [ref, message, date] = line.split("\t");
+ return {
+ index,
+ ref: String(ref || "").trim(),
+ message: String(message || "").trim(),
+ date: String(date || "").trim(),
+ };
+ });
+
+ return ok(stashes);
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+async function stashPush(workspacePath, message = null) {
+ try {
+ const repoRoot = await findRepoRoot(workspacePath);
+ if (!repoRoot) return fail("Not a git repository.");
+
+ const args = ["stash", "push"];
+ if (message) args.push("-m", message);
+
+ await git(repoRoot).raw(args);
+ return ok({});
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+async function stashPop(workspacePath, index = null) {
+ try {
+ const repoRoot = await findRepoRoot(workspacePath);
+ if (!repoRoot) return fail("Not a git repository.");
+
+ const args = ["stash", "pop"];
+ if (index !== null && index !== undefined) args.push(`stash@{${index}}`);
+
+ await git(repoRoot).raw(args);
+ return ok({});
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+async function stashDrop(workspacePath, index) {
+ try {
+ const repoRoot = await findRepoRoot(workspacePath);
+ if (!repoRoot) return fail("Not a git repository.");
+
+ await git(repoRoot).raw(["stash", "drop", `stash@{${index}}`]);
+ return ok({});
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+// ─── Remotes ──────────────────────────────────────────────────────────────────
+
+async function listRemotes(workspacePath) {
+ try {
+ const repoRoot = await findRepoRoot(workspacePath);
+ if (!repoRoot) return fail("Not a git repository.");
+
+ const output = await git(repoRoot).raw(["remote", "-v"]);
+ const seen = new Map();
+
+ String(output || "")
+ .split("\n")
+ .filter(Boolean)
+ .forEach((line) => {
+ const match = line.match(/^(\S+)\s+(\S+)\s+\((\w+)\)$/);
+ if (!match) return;
+ const [, name, url, type] = match;
+ if (!seen.has(name)) seen.set(name, { name, fetchUrl: "", pushUrl: "" });
+ const entry = seen.get(name);
+ if (type === "fetch") entry.fetchUrl = url;
+ if (type === "push") entry.pushUrl = url;
+ });
+
+ return ok([...seen.values()]);
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+async function addRemote(workspacePath, name, url) {
+ try {
+ const repoRoot = await findRepoRoot(workspacePath);
+ if (!repoRoot) return fail("Not a git repository.");
+
+ await git(repoRoot).addRemote(name, url);
+ return ok({ name, url });
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+async function removeRemote(workspacePath, name) {
+ try {
+ const repoRoot = await findRepoRoot(workspacePath);
+ if (!repoRoot) return fail("Not a git repository.");
+
+ await git(repoRoot).removeRemote(name);
+ return ok({ name });
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+/**
+ * Build env vars for PAT auth on HTTPS remotes.
+ * We embed the token into the URL via a credential helper.
+ */
+function buildAuthEnv(auth) {
+ if (!auth || auth.type === "ssh") return {};
+ if (auth.type === "pat" && auth.token) {
+ // Git credential helper approach: set GIT_ASKPASS to a script that returns the token
+ // Simpler: pass via URL rewrite in the caller
+ return { GIT_TERMINAL_PROMPT: "0" };
+ }
+ return {};
+}
+
+/**
+ * Inject PAT into HTTPS URL if provided.
+ */
+function injectPAT(url, token) {
+ if (!token || !url) return url;
+ try {
+ const u = new URL(url);
+ u.username = "token";
+ u.password = token;
+ return u.toString();
+ } catch {
+ return url;
+ }
+}
+
+async function push(workspacePath, options = {}) {
+ try {
+ const repoRoot = await findRepoRoot(workspacePath);
+ if (!repoRoot) return fail("Not a git repository.");
+
+ const { remote = "origin", branch, auth } = options;
+ const g = git(repoRoot);
+
+ // For PAT auth, rewrite the remote URL temporarily
+ let remoteUrl = null;
+ if (auth?.type === "pat" && auth?.token) {
+ try {
+ const remoteOutput = await g.raw(["remote", "get-url", remote]);
+ remoteUrl = injectPAT(String(remoteOutput || "").trim(), auth.token);
+ await g.raw(["remote", "set-url", remote, remoteUrl]);
+ } catch {
+ // Ignore if remote doesn't exist yet
+ }
+ }
+
+ const pushArgs = branch ? [remote, branch] : [remote];
+ const result = await g.push(pushArgs);
+
+ // Restore original URL (remove embedded credentials)
+ if (remoteUrl) {
+ try {
+ const originalUrl = remoteUrl.replace(/\/\/[^@]+@/, "//");
+ await g.raw(["remote", "set-url", remote, originalUrl]);
+ } catch {
+ // Best effort
+ }
+ }
+
+ return ok({ result: String(result || "") });
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+async function pull(workspacePath, options = {}) {
+ try {
+ const repoRoot = await findRepoRoot(workspacePath);
+ if (!repoRoot) return fail("Not a git repository.");
+
+ const { remote = "origin", branch, auth } = options;
+ const g = git(repoRoot);
+
+ let remoteUrl = null;
+ if (auth?.type === "pat" && auth?.token) {
+ try {
+ const remoteOutput = await g.raw(["remote", "get-url", remote]);
+ remoteUrl = injectPAT(String(remoteOutput || "").trim(), auth.token);
+ await g.raw(["remote", "set-url", remote, remoteUrl]);
+ } catch {
+ // Ignore
+ }
+ }
+
+ const pullArgs = branch ? [remote, branch] : [remote];
+ const result = await g.pull(...pullArgs);
+
+ if (remoteUrl) {
+ try {
+ const originalUrl = remoteUrl.replace(/\/\/[^@]+@/, "//");
+ await g.raw(["remote", "set-url", remote, originalUrl]);
+ } catch {
+ // Best effort
+ }
+ }
+
+ return ok({
+ files: result?.files || [],
+ insertions: result?.summary?.insertions || 0,
+ deletions: result?.summary?.deletions || 0,
+ });
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+async function fetch(workspacePath, options = {}) {
+ try {
+ const repoRoot = await findRepoRoot(workspacePath);
+ if (!repoRoot) return fail("Not a git repository.");
+
+ const { remote, auth } = options;
+ const g = git(repoRoot);
+
+ let remoteUrl = null;
+ const targetRemote = remote || "origin";
+ if (auth?.type === "pat" && auth?.token) {
+ try {
+ const remoteOutput = await g.raw(["remote", "get-url", targetRemote]);
+ remoteUrl = injectPAT(String(remoteOutput || "").trim(), auth.token);
+ await g.raw(["remote", "set-url", targetRemote, remoteUrl]);
+ } catch {
+ // Ignore
+ }
+ }
+
+ await g.fetch(remote ? [remote] : []);
+
+ if (remoteUrl) {
+ try {
+ const originalUrl = remoteUrl.replace(/\/\/[^@]+@/, "//");
+ await g.raw(["remote", "set-url", targetRemote, originalUrl]);
+ } catch {
+ // Best effort
+ }
+ }
+
+ return ok({});
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+// ─── Search ───────────────────────────────────────────────────────────────────
+
+/**
+ * Search commits by message, author, date range, or file path.
+ * @param {string} workspacePath
+ * @param {{ query: string, type: 'message'|'author'|'file'|'date' }}
+ */
+async function search(workspacePath, options) {
+ try {
+ const repoRoot = await findRepoRoot(workspacePath);
+ if (!repoRoot) return fail("Not a git repository.");
+
+ const { query, type = "message" } = options;
+ if (!query) return ok([]);
+
+ const g = git(repoRoot);
+ const logOptions = {
+ format: LOG_FORMAT,
+ maxCount: 200,
+ };
+
+ if (type === "message") {
+ logOptions["--grep"] = query;
+ logOptions["--regexp-ignore-case"] = null;
+ } else if (type === "author") {
+ logOptions["--author"] = query;
+ logOptions["--regexp-ignore-case"] = null;
+ } else if (type === "file") {
+ const logResult = await g.log({
+ ...logOptions,
+ file: query,
+ });
+ return ok(
+ (logResult.all || []).map((c) => ({
+ hash: c.hash,
+ shortHash: c.shortHash || shortHash(c.hash),
+ message: c.message,
+ author: c.authorName,
+ date: c.date,
+ }))
+ );
+ } else if (type === "date") {
+ logOptions["--after"] = query;
+ }
+
+ const result = await g.log(logOptions);
+ return ok(
+ (result.all || []).map((c) => ({
+ hash: c.hash,
+ shortHash: c.shortHash || shortHash(c.hash),
+ message: c.message,
+ author: c.authorName,
+ date: c.date,
+ }))
+ );
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+// ─── Deleted Files ────────────────────────────────────────────────────────────
+
+/**
+ * Find files that existed in git history but have since been deleted.
+ */
+async function getDeletedFiles(workspacePath) {
+ try {
+ const repoRoot = await findRepoRoot(workspacePath);
+ if (!repoRoot) return fail("Not a git repository.");
+
+ const output = await git(repoRoot).raw([
+ "log",
+ "--diff-filter=D",
+ "--name-only",
+ "--format=%H\t%s\t%aI",
+ ]);
+
+ const lines = String(output || "").split("\n");
+ const results = [];
+ let currentCommit = null;
+
+ for (const line of lines) {
+ if (!line.trim()) continue;
+ if (line.includes("\t")) {
+ const parts = line.split("\t");
+ currentCommit = {
+ hash: parts[0],
+ message: parts[1] || "",
+ date: parts[2] || "",
+ };
+ } else if (currentCommit) {
+ results.push({
+ path: line.trim(),
+ lastCommit: currentCommit.hash,
+ lastMessage: currentCommit.message,
+ lastDate: currentCommit.date,
+ });
+ }
+ }
+
+ return ok(results);
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+// ─── Stats ────────────────────────────────────────────────────────────────────
+
+async function getWorkspaceStats(workspacePath) {
+ try {
+ const repoRoot = await findRepoRoot(workspacePath);
+ if (!repoRoot) return fail("Not a git repository.");
+
+ const g = git(repoRoot);
+
+ const [countOutput, branchResult, tagResult, contributorOutput] = await Promise.all([
+ g.raw(["rev-list", "--count", "HEAD"]).catch(() => "0"),
+ g.branch(["-a"]).catch(() => ({ all: [] })),
+ g.tags().catch(() => ({ all: [] })),
+ g.raw(["shortlog", "-sn", "--no-merges"]).catch(() => ""),
+ ]);
+
+ const totalCommits = parseInt(String(countOutput || "0").trim(), 10) || 0;
+ const branchCount = (branchResult.all || []).filter((b) => !b.includes("HEAD")).length;
+ const tagCount = (tagResult.all || []).length;
+
+ const contributors = String(contributorOutput || "")
+ .split("\n")
+ .filter(Boolean)
+ .map((line) => {
+ const match = line.match(/^\s*(\d+)\s+(.+)$/);
+ return match ? { commits: parseInt(match[1], 10), name: match[2].trim() } : null;
+ })
+ .filter(Boolean);
+
+ // Rough repo size
+ let repoSizeBytes = 0;
+ const gitDir = path.join(repoRoot, ".git");
+ if (fs.existsSync(gitDir)) {
+ try {
+ const packDir = path.join(gitDir, "objects", "pack");
+ if (fs.existsSync(packDir)) {
+ const files = fs.readdirSync(packDir);
+ for (const f of files) {
+ try {
+ repoSizeBytes += fs.statSync(path.join(packDir, f)).size;
+ } catch {
+ // Ignore
+ }
+ }
+ }
+ } catch {
+ // Best effort
+ }
+ }
+
+ return ok({
+ totalCommits,
+ branches: branchCount,
+ tags: tagCount,
+ contributors,
+ repoSizeBytes,
+ repoRoot,
+ });
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+// ─── Migration from Legacy ────────────────────────────────────────────────────
+
+const MIGRATION_FLAG = ".git-migration-done";
+
+/**
+ * One-time migration from the legacy file-backed version history into git commits.
+ * Idempotent — safe to call multiple times.
+ *
+ * @param {string} workspacePath
+ * @param {object|null} metadataStore — the existing MetadataStore instance
+ * @returns {{ ok: true, data: { migrated: number, skipped: number, alreadyMigrated: bool } }}
+ */
+async function migrateFromLegacy(workspacePath, metadataStore = null) {
+ try {
+ const notesAppDir = path.join(workspacePath, ".notes-app");
+ const flagFile = path.join(notesAppDir, MIGRATION_FLAG);
+
+ // Already done
+ if (fs.existsSync(flagFile)) {
+ return ok({ alreadyMigrated: true, migrated: 0, skipped: 0 });
+ }
+
+ // No metadata store = nothing to migrate
+ if (!metadataStore || typeof metadataStore.getWorkspaceActivity !== "function") {
+ ensureDir(notesAppDir);
+ fs.writeFileSync(flagFile, nowIso(), "utf8");
+ return ok({ alreadyMigrated: false, migrated: 0, skipped: 0 });
+ }
+
+ // Ensure we have a git repo to commit into
+ const repoInfo = await getRepoInfo(workspacePath);
+ if (!repoInfo.ok) return repoInfo;
+
+ if (!repoInfo.data.isRepo) {
+ const initResult = await initRepo(workspacePath);
+ if (!initResult.ok) return initResult;
+ }
+
+ const repoRoot = repoInfo.data.repoRoot || (await findRepoRoot(workspacePath));
+ if (!repoRoot) return fail("Unable to determine repo root after init.");
+
+ // Get legacy history entries for this workspace (oldest first)
+ const entries = metadataStore.getWorkspaceActivity(workspacePath, 5000);
+ const sorted = [...(entries || [])].sort((a, b) =>
+ (a.createdAt || "").localeCompare(b.createdAt || "")
+ );
+
+ const g = git(repoRoot);
+ let migrated = 0;
+ let skipped = 0;
+
+ for (const entry of sorted) {
+ const versionPath = String(entry.versionPath || "");
+
+ // Only migrate file-backed versions
+ if (!versionPath || versionPath.startsWith("p2p://") || !versionPath.endsWith(".md")) {
+ skipped += 1;
+ continue;
+ }
+
+ if (!fs.existsSync(versionPath)) {
+ skipped += 1;
+ continue;
+ }
+
+ try {
+ const content = fs.readFileSync(versionPath, "utf8");
+ const targetPath = path.resolve(String(entry.filePath || ""));
+
+ if (!targetPath || !fs.existsSync(path.dirname(targetPath))) {
+ skipped += 1;
+ continue;
+ }
+
+ // Write the snapshot content to the original file
+ fs.writeFileSync(targetPath, content, "utf8");
+
+ const relPath = path.relative(repoRoot, targetPath).replace(/\\/g, "/");
+ await g.add(relPath);
+
+ const reason = String(entry.reason || "snapshot").replace(/[^a-z0-9-_ ]/gi, "");
+ const date = String(entry.createdAt || nowIso()).slice(0, 10);
+ const message = `Migrated: ${reason} (${date})`;
+
+ // Preserve original timestamp if possible
+ const commitDate = entry.createdAt || nowIso();
+ await g.env({
+ GIT_AUTHOR_DATE: commitDate,
+ GIT_COMMITTER_DATE: commitDate,
+ }).commit(message, ["--allow-empty"]);
+
+ migrated += 1;
+ } catch {
+ skipped += 1;
+ }
+ }
+
+ // Write migration flag
+ ensureDir(notesAppDir);
+ fs.writeFileSync(flagFile, nowIso(), "utf8");
+
+ return ok({ alreadyMigrated: false, migrated, skipped });
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+// ─── Gitignore Managed Block ──────────────────────────────────────────────────
+
+const MANAGED_BLOCK_START = "# >>> Notes App Managed >>>";
+const MANAGED_BLOCK_END = "# <<< Notes App Managed <<<";
+
+/**
+ * Ensure the managed .notes-app/ block exists in .gitignore.
+ * Only modifies the managed block — never touches user rules.
+ */
+async function ensureManagedGitignoreBlock(repoRoot) {
+ try {
+ const gitignorePath = path.join(repoRoot, ".gitignore");
+ const existing = fs.existsSync(gitignorePath)
+ ? fs.readFileSync(gitignorePath, "utf8")
+ : "";
+
+ if (existing.includes(MANAGED_BLOCK_START)) return ok({ changed: false });
+
+ const block = `\n${MANAGED_BLOCK_START}\n.notes-app/\n${MANAGED_BLOCK_END}\n`;
+ const needsNewline = existing.length > 0 && !existing.endsWith("\n");
+ fs.writeFileSync(gitignorePath, `${existing}${needsNewline ? "\n" : ""}${block}`, "utf8");
+
+ return ok({ changed: true });
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+/**
+ * Remove only the managed block from .gitignore.
+ * Never modifies user rules.
+ */
+async function removeManagedGitignoreBlock(repoRoot) {
+ try {
+ const gitignorePath = path.join(repoRoot, ".gitignore");
+ if (!fs.existsSync(gitignorePath)) return ok({ changed: false });
+
+ const existing = fs.readFileSync(gitignorePath, "utf8");
+ const startIdx = existing.indexOf(MANAGED_BLOCK_START);
+ const endIdx = existing.indexOf(MANAGED_BLOCK_END);
+
+ if (startIdx === -1) return ok({ changed: false });
+
+ const endPos = endIdx !== -1 ? endIdx + MANAGED_BLOCK_END.length : existing.length;
+ const before = existing.slice(0, startIdx).replace(/\n+$/, "");
+ const after = existing.slice(endPos);
+ const next = `${before}${after}`.replace(/^\n+/, "");
+
+ fs.writeFileSync(gitignorePath, next.length > 0 ? `${next}\n` : "", "utf8");
+ return ok({ changed: true });
+ } catch (err) {
+ return fail(err);
+ }
+}
+
+// ─── Exports ──────────────────────────────────────────────────────────────────
+
+module.exports = {
+ detectGit,
+ findRepoRoot,
+ getRepoInfo,
+ initRepo,
+ getStatus,
+ getLog,
+ getCommitFiles,
+ getFileAtCommit,
+ getFileDiff,
+ commit,
+ restoreFileAtCommit,
+ listBranches,
+ createBranch,
+ renameBranch,
+ deleteBranch,
+ switchBranch,
+ mergeBranch,
+ listTags,
+ createTag,
+ deleteTag,
+ stashList,
+ stashPush,
+ stashPop,
+ stashDrop,
+ listRemotes,
+ addRemote,
+ removeRemote,
+ push,
+ pull,
+ fetch,
+ search,
+ getDeletedFiles,
+ getWorkspaceStats,
+ migrateFromLegacy,
+ ensureManagedGitignoreBlock,
+ removeManagedGitignoreBlock,
+};
diff --git a/electron/main.cjs b/electron/main.cjs
index 38270ff..e94f41e 100644
--- a/electron/main.cjs
+++ b/electron/main.cjs
@@ -40,6 +40,9 @@ const { createMainHelpers } = require("./lib/core/mainHelpers.cjs");
const { registerWorkspaceExportIpcHandlers } = require("./lib/export/workspaceExportIpc.cjs");
const { setupDiagramHandlers } = require("./diagram-handlers.cjs");
const { initializeAIHandlers } = require("./ai/aiHandlers.cjs");
+const { registerGitIpcHandlers } = require("./lib/git/gitIpc.cjs");
+const gitService = require("./lib/git/gitService.cjs");
+
const rendererUrl = process.env.ELECTRON_RENDERER_URL;
const projectRoot = app.getAppPath();
@@ -460,8 +463,22 @@ function applyNotesRoot(nextRootPath) {
p2pService.init();
activeProjectSlug = ROOT_PROJECT_SLUG;
+
+ // Trigger one-time legacy history migration (async, best-effort, non-blocking)
+ gitService.detectGit().then((detection) => {
+ if (!detection.ok || !detection.data.available) return;
+ gitService.migrateFromLegacy(notesRoot, metadataStore).then((result) => {
+ if (!result.ok) return;
+ if (!result.data.alreadyMigrated && result.data.migrated > 0) {
+ console.log(`[git] Migrated ${result.data.migrated} legacy version(s) to git commits.`);
+ }
+ }).catch((err) => {
+ console.warn("[git] Legacy migration error:", err?.message || err);
+ });
+ });
}
+
function readP2PStatusSnapshot() {
return mainHelpers.readP2PStatusSnapshot();
}
@@ -944,3 +961,9 @@ setupDiagramHandlers(ipcMain, appDataDir, {
emitLocalP2PSyncEvent: (payload) => p2pSyncEngine.emitLocalP2PSyncEvent(payload),
hashContent,
});
+
+registerGitIpcHandlers(ipcMain, {
+ assertTrustedIpcSender,
+ BrowserWindow,
+ getNotesRoot: () => notesRoot,
+});
diff --git a/electron/preload.cjs b/electron/preload.cjs
index 590e584..607dd8f 100644
--- a/electron/preload.cjs
+++ b/electron/preload.cjs
@@ -99,10 +99,6 @@ contextBridge.exposeInMainWorld("notesApi", {
ipcRenderer.on("document:changed-on-disk", listener);
return () => ipcRenderer.removeListener("document:changed-on-disk", listener);
},
- getHistory: (filePath) => ipcRenderer.invoke("documents:history", filePath),
- restoreHistory: (payload) => ipcRenderer.invoke("documents:restore", payload),
- readVersion: (payload) => ipcRenderer.invoke("documents:read-version", payload),
- deleteVersion: (payload) => ipcRenderer.invoke("documents:delete-version", payload),
readDiagramSource: (payload) => ipcRenderer.invoke("diagram:read-source", payload),
writeDiagramSource: (payload) => ipcRenderer.invoke("diagram:write-source", payload),
writeDiagramImage: (payload) => ipcRenderer.invoke("diagram:write-image", payload),
@@ -156,5 +152,43 @@ contextBridge.exposeInMainWorld("notesApi", {
const listener = (_event, payload) => callback(payload);
ipcRenderer.on("terminal:exit", listener);
return () => ipcRenderer.removeListener("terminal:exit", listener);
- }
+ },
+
+ // ── Git Version Control ────────────────────────────────────────────────────
+ gitDetect: () => ipcRenderer.invoke("git:detect"),
+ gitGetRepoInfo: (payload) => ipcRenderer.invoke("git:get-repo-info", payload),
+ gitInitRepo: (payload) => ipcRenderer.invoke("git:init-repo", payload),
+ gitGetStatus: (payload) => ipcRenderer.invoke("git:get-status", payload),
+ gitGetLog: (payload) => ipcRenderer.invoke("git:get-log", payload),
+ gitGetCommitFiles: (payload) => ipcRenderer.invoke("git:get-commit-files", payload),
+ gitGetFileAtCommit: (payload) => ipcRenderer.invoke("git:get-file-at-commit", payload),
+ gitGetFileDiff: (payload) => ipcRenderer.invoke("git:get-file-diff", payload),
+ gitCommit: (payload) => ipcRenderer.invoke("git:commit", payload),
+ gitRestoreFileAtCommit: (payload) => ipcRenderer.invoke("git:restore-file-at-commit", payload),
+ gitListBranches: (payload) => ipcRenderer.invoke("git:list-branches", payload),
+ gitCreateBranch: (payload) => ipcRenderer.invoke("git:create-branch", payload),
+ gitRenameBranch: (payload) => ipcRenderer.invoke("git:rename-branch", payload),
+ gitDeleteBranch: (payload) => ipcRenderer.invoke("git:delete-branch", payload),
+ gitSwitchBranch: (payload) => ipcRenderer.invoke("git:switch-branch", payload),
+ gitMergeBranch: (payload) => ipcRenderer.invoke("git:merge-branch", payload),
+ gitListTags: (payload) => ipcRenderer.invoke("git:list-tags", payload),
+ gitCreateTag: (payload) => ipcRenderer.invoke("git:create-tag", payload),
+ gitDeleteTag: (payload) => ipcRenderer.invoke("git:delete-tag", payload),
+ gitStashList: (payload) => ipcRenderer.invoke("git:stash-list", payload),
+ gitStashPush: (payload) => ipcRenderer.invoke("git:stash-push", payload),
+ gitStashPop: (payload) => ipcRenderer.invoke("git:stash-pop", payload),
+ gitStashDrop: (payload) => ipcRenderer.invoke("git:stash-drop", payload),
+ gitListRemotes: (payload) => ipcRenderer.invoke("git:list-remotes", payload),
+ gitAddRemote: (payload) => ipcRenderer.invoke("git:add-remote", payload),
+ gitRemoveRemote: (payload) => ipcRenderer.invoke("git:remove-remote", payload),
+ gitPush: (payload) => ipcRenderer.invoke("git:push", payload),
+ gitPull: (payload) => ipcRenderer.invoke("git:pull", payload),
+ gitFetch: (payload) => ipcRenderer.invoke("git:fetch", payload),
+ gitSearch: (payload) => ipcRenderer.invoke("git:search", payload),
+ gitGetDeletedFiles: (payload) => ipcRenderer.invoke("git:get-deleted-files", payload),
+ gitGetWorkspaceStats: (payload) => ipcRenderer.invoke("git:get-workspace-stats", payload),
+ gitMigrateLegacy: (payload) => ipcRenderer.invoke("git:migrate-legacy", payload),
+ gitEnsureManagedGitignore: (payload) => ipcRenderer.invoke("git:ensure-managed-gitignore", payload),
+ gitRemoveManagedGitignore: (payload) => ipcRenderer.invoke("git:remove-managed-gitignore", payload),
});
+
diff --git a/package-lock.json b/package-lock.json
index de090bc..78024a5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -33,6 +33,7 @@
"remark-gfm": "^4.0.1",
"remark-lint": "^10.0.1",
"remark-preset-lint-recommended": "^7.0.1",
+ "simple-git": "^3.36.0",
"uuid": "^14.0.1",
"yazl": "^3.3.1"
},
@@ -2738,6 +2739,21 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@kwsites/file-exists": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz",
+ "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.1.1"
+ }
+ },
+ "node_modules/@kwsites/promise-deferred": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz",
+ "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==",
+ "license": "MIT"
+ },
"node_modules/@lezer/common": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
@@ -4124,6 +4140,21 @@
"win32"
]
},
+ "node_modules/@simple-git/args-pathspec": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@simple-git/args-pathspec/-/args-pathspec-1.0.3.tgz",
+ "integrity": "sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA==",
+ "license": "MIT"
+ },
+ "node_modules/@simple-git/argv-parser": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@simple-git/argv-parser/-/argv-parser-1.1.1.tgz",
+ "integrity": "sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw==",
+ "license": "MIT",
+ "dependencies": {
+ "@simple-git/args-pathspec": "^1.0.3"
+ }
+ },
"node_modules/@sindresorhus/is": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
@@ -13708,6 +13739,23 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/simple-git": {
+ "version": "3.36.0",
+ "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.36.0.tgz",
+ "integrity": "sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@kwsites/file-exists": "^1.1.1",
+ "@kwsites/promise-deferred": "^1.1.1",
+ "@simple-git/args-pathspec": "^1.0.3",
+ "@simple-git/argv-parser": "^1.1.0",
+ "debug": "^4.4.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/steveukx/git-js?sponsor=1"
+ }
+ },
"node_modules/simple-update-notifier": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
diff --git a/package.json b/package.json
index cb5e299..4d12c85 100644
--- a/package.json
+++ b/package.json
@@ -56,6 +56,7 @@
"remark-gfm": "^4.0.1",
"remark-lint": "^10.0.1",
"remark-preset-lint-recommended": "^7.0.1",
+ "simple-git": "^3.36.0",
"uuid": "^14.0.1",
"yazl": "^3.3.1"
},
diff --git a/src/App.jsx b/src/App.jsx
index 81fb562..cd40936 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1,4 +1,4 @@
-import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react";
+import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { NotebookPen, Terminal, X } from "lucide-react";
import { DocumentList } from "./components/DocumentList";
import { ErrorBoundary } from "./components/ErrorBoundary";
@@ -41,6 +41,14 @@ const GlobalSearchOverlay = lazy(() =>
const KeyboardShortcutsModal = lazy(() =>
import("./components/KeyboardShortcutsModal").then((m) => ({ default: m.KeyboardShortcutsModal }))
);
+const GitVersionControlPage = lazy(() =>
+ import("./components/GitVersionControlPage").then((m) => ({ default: m.GitVersionControlPage }))
+);
+const GitCommitDialog = lazy(() =>
+ import("./components/GitCommitDialog").then((m) => ({ default: m.GitCommitDialog }))
+);
+import { GitStatusBar } from "./components/GitStatusBar";
+
const TasksPanel = lazy(() =>
import("./components/TasksPanel").then((m) => ({ default: m.TasksPanel }))
);
@@ -66,7 +74,6 @@ import {
onMenuAction,
notifyBootReady,
notifyBootProgress,
- getHistory,
getAppInfo,
getHelpDocuments,
getDashboardCache,
@@ -88,6 +95,7 @@ import {
setOnboardingComplete,
getNotesRootSetting,
setNotesRootSetting,
+ gitGetStatus,
} from "./services/electronService";
import { useToast } from "./hooks/useToast";
import { useP2PSync } from "./hooks/useP2PSync";
@@ -301,6 +309,9 @@ export default function App() {
const [helpCenterOpen, setHelpCenterOpen] = useState(false);
const [markdownGuideOpen, setMarkdownGuideOpen] = useState(false);
const [aboutOpen, setAboutOpen] = useState(false);
+ const [gitVCOpen, setGitVCOpen] = useState(false);
+ const [gitVCInitialTab, setGitVCInitialTab] = useState("status");
+ const [globalCommitDialogOpen, setGlobalCommitDialogOpen] = useState(false);
const [tasksPanelOpen, setTasksPanelOpen] = useState(false);
const [allTasksPanelOpen, setAllTasksPanelOpen] = useState(false);
const [recentNotesPanelOpen, setRecentNotesPanelOpen] = useState(false);
@@ -335,6 +346,8 @@ export default function App() {
branch: "",
autoIgnoreMetadataInGit: true,
gitignoreHasNotesApp: false,
+ pendingCount: 0,
+ files: [],
});
const {
@@ -664,17 +677,30 @@ export default function App() {
async function refreshGitWorkspaceMeta() {
try {
const meta = await getGitWorkspaceMetadata();
+ let pendingCount = 0;
+ let files = [];
+ if (meta?.isGitRoot && notesFolderPath) {
+ const statusResult = await gitGetStatus(notesFolderPath);
+ if (statusResult?.ok) {
+ pendingCount = statusResult.data.files?.length || 0;
+ files = statusResult.data.files || [];
+ }
+ }
setGitWorkspaceMeta({
workspaceRoot: String(meta?.workspaceRoot || ""),
isGitRoot: meta?.isGitRoot === true,
branch: String(meta?.branch || ""),
autoIgnoreMetadataInGit: meta?.autoIgnoreMetadataInGit !== false,
gitignoreHasNotesApp: meta?.gitignoreHasNotesApp === true,
+ pendingCount,
+ files,
});
} catch {
setGitWorkspaceMeta((currentValue) => ({
...currentValue,
isGitRoot: false,
+ pendingCount: 0,
+ files: [],
}));
}
}
@@ -699,10 +725,20 @@ export default function App() {
}
}
+ const handleGitStateChange = useCallback(({ branch, pendingCount }) => {
+ setGitWorkspaceMeta((meta) => {
+ if (meta.branch === branch && meta.pendingCount === pendingCount) return meta;
+ return {
+ ...meta,
+ branch,
+ pendingCount,
+ };
+ });
+ }, []);
+
useEffect(() => {
void refreshGitWorkspaceMeta();
- // notesFolderPath updates when notes root changes.
- }, [notesFolderPath]);
+ }, [notesFolderPath, currentFilePath, dirty]);
useEffect(() => {
function onGlobalKeyDown(event) {
@@ -857,7 +893,7 @@ export default function App() {
useEffect(() => {
const handleGlobalKeyDown = (e) => {
- if (e.key.toLowerCase() === "m" && (e.ctrlKey || e.metaKey)) {
+ if (e.key && e.key.toLowerCase() === "m" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
setMarkdownGuideOpen(true);
}
@@ -1022,6 +1058,73 @@ export default function App() {
return;
}
+ if (action === "open-git-version-control") {
+ setGitVCInitialTab("status");
+ setGitVCOpen(true);
+ return;
+ }
+
+ if (action === "git-commit") {
+ setGlobalCommitDialogOpen(true);
+ return;
+ }
+
+ if (action === "git-history") {
+ if (current) {
+ setDocumentMenuAction({ action, nonce: Date.now() });
+ } else {
+ notify("Open a note to view its history.", "info");
+ }
+ return;
+ }
+
+ if (action === "git-diff-current") {
+ if (current) {
+ setDocumentMenuAction({ action, nonce: Date.now() });
+ } else {
+ notify("Open a note to diff.", "info");
+ }
+ return;
+ }
+
+ if (action === "git-compare") {
+ setGitVCInitialTab("compare");
+ setGitVCOpen(true);
+ return;
+ }
+
+ if (action === "git-create-branch" || action === "git-switch-branch" || action === "git-merge-branch") {
+ setGitVCInitialTab("branches");
+ setGitVCOpen(true);
+ return;
+ }
+
+ if (action === "git-tags") {
+ setGitVCInitialTab("tags");
+ setGitVCOpen(true);
+ return;
+ }
+
+ if (action === "git-stash") {
+ setGitVCInitialTab("stashes");
+ setGitVCOpen(true);
+ return;
+ }
+
+ if (action === "git-push" || action === "git-pull" || action === "git-fetch" || action === "git-sync") {
+ setGitVCInitialTab("remotes");
+ setGitVCOpen(true);
+ return;
+ }
+
+ if (action === "toggle-auto-ignore-git-metadata") {
+ void handleToggleAutoIgnoreGitMetadata();
+ return;
+ }
+
+
+
+
if (action === "open-p2p-sync-help") {
setP2PSyncHelpOpen(true);
return;
@@ -1214,9 +1317,7 @@ export default function App() {
}
if (action === "manage-versions") {
- if (current) {
- setDocumentMenuAction({ action, nonce: Date.now() });
- }
+ // Deprecated version action
return;
}
@@ -2105,24 +2206,16 @@ export default function App() {
{activeProject?.isRoot ? "Root" : activeProject?.name || "Project"}
-
- {gitWorkspaceMeta.isGitRoot ? `Git: ${gitWorkspaceMeta.branch || "(unknown)"}` : "Git: not root"}
-
-
- {gitWorkspaceMeta.isGitRoot
- ? (gitWorkspaceMeta.gitignoreHasNotesApp ? ".notes-app ignored" : ".notes-app tracked")
- : ".notes-app n/a"}
-
- {
- void handleToggleAutoIgnoreGitMetadata();
+
- {gitWorkspaceMeta.autoIgnoreMetadataInGit ? "Ignore .notes-app: On" : "Ignore .notes-app: Off"}
-
+ onClick={() => setGitVCOpen(true)}
+ />
{current ? (
<>
@@ -2296,6 +2389,8 @@ export default function App() {
setHistory(await getHistory(current.filePath))}
+ onRefreshHistory={async () => setHistory([])}
saving={saving}
dirty={dirty}
menuAction={documentMenuAction}
@@ -2915,6 +3010,40 @@ export default function App() {
)}
+ {gitVCOpen && (
+
+ Loading Version Control…
}>
+ setGitVCOpen(false)}
+ onNotify={notify}
+ onGitStateChange={handleGitStateChange}
+ currentFilePath={current?.filePath}
+ initialTab={gitVCInitialTab}
+ documents={documents}
+ />
+
+
+ )}
+
+ {globalCommitDialogOpen && (
+
+ setGlobalCommitDialogOpen(false)}
+ onCommit={async (payload) => {
+ const result = await gitCommit({ workspacePath: notesFolderPath, ...payload });
+ if (!result?.ok) throw new Error(result?.error || "Commit failed.");
+ notify("Committed successfully.", "success");
+ void refreshGitWorkspaceMeta();
+ }}
+ stagedFiles={gitWorkspaceMeta.files || []}
+ workspacePath={notesFolderPath}
+ currentFilePath={current?.filePath}
+ />
+
+ )}
+
);
diff --git a/src/components/DocumentDetail.jsx b/src/components/DocumentDetail.jsx
index c8709f2..8eb0c51 100644
--- a/src/components/DocumentDetail.jsx
+++ b/src/components/DocumentDetail.jsx
@@ -38,7 +38,7 @@ import OverlayDialog from "./OverlayDialog";
import DialogSelectField from "./DialogSelectField";
import { formatDate } from "../utils/dateUtils";
import { downloadPdf } from "../services/electronService";
-import { deleteVersion, readVersion } from "../services/electronService";
+import { GitNoteHistoryPanel } from "./GitNoteHistoryPanel";
import { useDocumentEditorActions } from "../hooks/useDocumentEditorActions";
import { useWorkspaceScopedStorage } from "../hooks/useWorkspaceScopedStorage";
import { renderMarkdown } from "../utils/renderUtils";
@@ -767,6 +767,8 @@ const OutlinePanel = memo(function OutlinePanel({
export function DocumentDetail({
document,
history,
+ workspacePath,
+ branch,
activeTab,
setActiveTab,
mode,
@@ -2225,61 +2227,20 @@ export function DocumentDetail({
) : null}
{showHistoryPopover ? (
- setShowHistoryPopover(false)}
- ariaLabel="Versions"
- >
-
-
Versions
-
setShowHistoryPopover(false)} aria-label="Close versions dialog">
-
-
-
-
- {history.length ? (
-
- {history.map((entry) => (
-
-
{formatDate(entry.createdAt)}
-
{entry.reason}
-
-
handleCompareVersion(entry)}
- data-tooltip="Compare with latest"
- >
-
- Compare
-
-
handleRestoreVersion(entry)}
- data-tooltip="Restore this version into the editor"
- >
-
- Restore
-
-
handleDeleteVersion(entry)}
- data-tooltip="Delete this version"
- >
-
- Delete
-
-
-
- ))}
-
- ) : (
- Versions appear after the first save.
- )}
-
+ filePath={document?.filePath}
+ workspacePath={workspacePath}
+ branch={branch}
+ onNotify={onNotify}
+ onRestored={async () => {
+ // Trigger refresh/re-read of the note from disk
+ if (typeof onRefreshHistory === "function") {
+ await onRefreshHistory();
+ }
+ }}
+ />
) : null}
{showMediaManager ? (
diff --git a/src/components/GitCommitDialog.jsx b/src/components/GitCommitDialog.jsx
new file mode 100644
index 0000000..7c5abcf
--- /dev/null
+++ b/src/components/GitCommitDialog.jsx
@@ -0,0 +1,198 @@
+import { useState, useRef, useEffect } from "react";
+import { GitCommit, X, FilePlus2, FileEdit, FileMinus2, FileQuestion } from "lucide-react";
+import OverlayDialog from "./OverlayDialog";
+import AppButton from "./AppButton";
+import AppInput from "./AppInput";
+
+const MAX_MESSAGE_LENGTH = 72;
+
+/**
+ * GitCommitDialog — compact commit dialog.
+ * User writes a message and picks which files to stage.
+ *
+ * Props:
+ * open — bool
+ * onClose — () => void
+ * onCommit — ({ message, filePaths }) => Promise
+ * stagedFiles — [{ path, status }] — pre-selected files from git status
+ * workspacePath — string
+ * currentFilePath — string|null — pre-select the active note's file
+ */
+export function GitCommitDialog({
+ open,
+ onClose,
+ onCommit,
+ stagedFiles = [],
+ workspacePath,
+ currentFilePath = null,
+}) {
+ const [message, setMessage] = useState("");
+ const [selectedPaths, setSelectedPaths] = useState([]);
+ const [committing, setCommitting] = useState(false);
+ const [error, setError] = useState(null);
+ const messageRef = useRef(null);
+
+ // Pre-select current file first, then all modified, on open
+ useEffect(() => {
+ if (!open) return;
+ setMessage("");
+ setError(null);
+ setCommitting(false);
+
+ if (stagedFiles.length === 0) {
+ setSelectedPaths([]);
+ return;
+ }
+
+ // Pre-select: current file (if in list) first, then all
+ const allPaths = stagedFiles.map((f) => f.path);
+ setSelectedPaths(allPaths);
+ }, [open, stagedFiles, currentFilePath]);
+
+ function togglePath(filePath) {
+ setSelectedPaths((prev) =>
+ prev.includes(filePath) ? prev.filter((p) => p !== filePath) : [...prev, filePath]
+ );
+ }
+
+ async function handleCommit() {
+ const trimmed = message.trim();
+ if (!trimmed) {
+ setError("Commit message is required.");
+ return;
+ }
+ if (selectedPaths.length === 0) {
+ setError("Select at least one file to commit.");
+ return;
+ }
+
+ setCommitting(true);
+ setError(null);
+
+ try {
+ await onCommit({ message: trimmed, filePaths: selectedPaths });
+ setMessage("");
+ onClose?.();
+ } catch (err) {
+ setError(err?.message || "Commit failed.");
+ } finally {
+ setCommitting(false);
+ }
+ }
+
+ function handleKeyDown(e) {
+ if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
+ e.preventDefault();
+ handleCommit();
+ }
+ }
+
+ const charLeft = MAX_MESSAGE_LENGTH - message.length;
+ const isOverLimit = charLeft < 0;
+ const canCommit = message.trim().length > 0 && selectedPaths.length > 0 && !committing;
+
+ return (
+
+
+
+
+
Commit Changes
+
+
+
+
+
+
+
+ {stagedFiles.length === 0 ? (
+
+ No changed files to commit. Save your notes first.
+
+ ) : (
+ <>
+
+ Files to commit
+ {stagedFiles.map((f) => (
+
+ togglePath(f.path)}
+ disabled={committing}
+ />
+ {(() => {
+ const status = f.status || "M";
+ if (status === "A") return ;
+ if (status === "D") return ;
+ if (status === "?" || status === "U") return ;
+ return ;
+ })()}
+
+ {f.path}
+
+
+ ))}
+
+
+
+
+ Commit message
+
+ {isOverLimit ? `${Math.abs(charLeft)} over limit` : `${charLeft} remaining`}
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+ >
+ )}
+
+
+
+
+ Cancel
+
+
+
+ {committing ? "Committing…" : "Commit"}
+
+
+
+ );
+}
+
+export default GitCommitDialog;
diff --git a/src/components/GitCommitTimeline.jsx b/src/components/GitCommitTimeline.jsx
new file mode 100644
index 0000000..be7a1af
--- /dev/null
+++ b/src/components/GitCommitTimeline.jsx
@@ -0,0 +1,318 @@
+import { useState } from "react";
+import {
+ GitCommit,
+ GitBranch,
+ Tag,
+ ChevronDown,
+ ChevronRight,
+ Copy,
+ RotateCcw,
+ GitCompare,
+ GitBranchPlus,
+} from "lucide-react";
+import AppButton from "./AppButton";
+
+function formatRelativeDate(isoDate) {
+ if (!isoDate) return "";
+ try {
+ const date = new Date(isoDate);
+ const now = Date.now();
+ const diff = now - date.getTime();
+ const minutes = Math.floor(diff / 60000);
+ if (minutes < 1) return "just now";
+ if (minutes < 60) return `${minutes}m ago`;
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return `${hours}h ago`;
+ const days = Math.floor(hours / 24);
+ if (days < 30) return `${days}d ago`;
+ const months = Math.floor(days / 30);
+ if (months < 12) return `${months}mo ago`;
+ return `${Math.floor(months / 12)}y ago`;
+ } catch {
+ return "";
+ }
+}
+
+function formatAbsoluteDate(isoDate) {
+ if (!isoDate) return "";
+ try {
+ return new Date(isoDate).toLocaleString();
+ } catch {
+ return "";
+ }
+}
+
+function CommitItem({
+ commit,
+ onCompare,
+ onRestore,
+ onCreateBranch,
+ onCreateTag,
+ selected,
+ onSelect,
+ compareMode,
+ compareA,
+}) {
+ const [expanded, setExpanded] = useState(false);
+ const [copied, setCopied] = useState(false);
+
+ const isCompareA = compareA === commit.hash;
+
+ function handleCopy() {
+ try {
+ navigator.clipboard.writeText(commit.hash);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ } catch {
+ // Ignore
+ }
+ }
+
+ return (
+ onSelect?.(commit.hash) : undefined}
+ onKeyDown={compareMode ? (e) => { if (e.key === "Enter" || e.key === " ") onSelect?.(commit.hash); } : undefined}
+ aria-pressed={compareMode ? selected : undefined}
+ >
+
+
+
+
+
+
+ {commit.shortHash || commit.hash?.slice(0, 7)}
+
+
+
+ {formatRelativeDate(commit.date)}
+
+
+
·
+
+
+ {commit.message}
+
+
+
·
+
+
+ {commit.author}
+
+
+ {commit.branches?.length > 0 && commit.branches.map((b) => (
+
+
+ {b}
+
+ ))}
+
+ {commit.tags?.length > 0 && commit.tags.map((t) => (
+
+
+ {t}
+
+ ))}
+
+ {!compareMode && (
+
+ {onCompare && (
+
{ e.stopPropagation(); onCompare(commit); }}
+ data-tooltip="Compare with another commit"
+ aria-label="Compare"
+ >
+
+ Compare
+
+ )}
+ {onRestore && (
+
{ e.stopPropagation(); onRestore(commit); }}
+ data-tooltip="Restore this version (creates a new commit)"
+ aria-label="Restore"
+ >
+
+ Restore
+
+ )}
+ {onCreateBranch && (
+
{ e.stopPropagation(); onCreateBranch(commit); }}
+ data-tooltip="Create a branch from this commit"
+ aria-label="Branch from here"
+ >
+
+ Branch
+
+ )}
+ {onCreateTag && (
+
{ e.stopPropagation(); onCreateTag(commit); }}
+ data-tooltip="Tag this commit"
+ aria-label="Tag"
+ >
+
+ Tag
+
+ )}
+
{ e.stopPropagation(); handleCopy(); }}
+ data-tooltip={copied ? "Copied!" : "Copy hash"}
+ aria-label="Copy hash"
+ >
+
+ {copied ? "Copied" : "Hash"}
+
+
+ )}
+
+
+ {commit.files?.length > 0 && (
+
{ e.stopPropagation(); setExpanded((v) => !v); }}
+ aria-expanded={expanded}
+ >
+ {expanded ? : }
+ {commit.files.length} file{commit.files.length === 1 ? "" : "s"} changed
+
+ )}
+
+ {expanded && commit.files?.length > 0 && (
+
+ {commit.files.map((f) => (
+
+ {f.status || "M"}
+ {f.path}
+
+ ))}
+
+ )}
+
+ );
+}
+
+/**
+ * GitCommitTimeline — reusable commit list with optional search, compare mode, and loading/empty states.
+ *
+ * Props:
+ * commits — array of commit objects
+ * loading — bool
+ * error — string|null
+ * onCompare — (commit) => void — called when Compare clicked
+ * onRestore — (commit) => void — called when Restore clicked
+ * onCreateBranch — (commit) => void
+ * onCreateTag — (commit) => void
+ * compareMode — bool — if true, items are selectable for diff comparison
+ * compareA — hash string — the first selected commit (highlighted differently)
+ * onSelectCommit — (hash) => void — called in compareMode on click
+ * emptyMessage — string — custom message when no commits
+ */
+export function GitCommitTimeline({
+ commits = [],
+ loading = false,
+ error = null,
+ onCompare,
+ onRestore,
+ onCreateBranch,
+ onCreateTag,
+ compareMode = false,
+ compareA = null,
+ onSelectCommit,
+ emptyMessage = "No commits yet.",
+ searchable = false,
+}) {
+ const [query, setQuery] = useState("");
+ const [selectedHash, setSelectedHash] = useState(null);
+
+ const filtered = searchable && query.trim()
+ ? commits.filter((c) =>
+ c.message?.toLowerCase().includes(query.toLowerCase()) ||
+ c.author?.toLowerCase().includes(query.toLowerCase()) ||
+ c.shortHash?.includes(query) ||
+ c.hash?.includes(query)
+ )
+ : commits;
+
+ if (loading) {
+ return (
+
+
+ Loading history…
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ {error}
+
+ );
+ }
+
+ return (
+
+ {searchable && (
+
+ setQuery(e.target.value)}
+ aria-label="Search commits"
+ />
+
+ )}
+
+ {filtered.length === 0 ? (
+
+
+
+ {query ? `No commits matching "${query}".` : emptyMessage}
+
+
+ ) : (
+
+ {filtered.map((commit) => (
+ {
+ setSelectedHash(hash);
+ onSelectCommit?.(hash);
+ }}
+ />
+ ))}
+
+ )}
+
+ );
+}
+
+export default GitCommitTimeline;
diff --git a/src/components/GitDiffViewer.jsx b/src/components/GitDiffViewer.jsx
new file mode 100644
index 0000000..e33f221
--- /dev/null
+++ b/src/components/GitDiffViewer.jsx
@@ -0,0 +1,417 @@
+import { useState, useEffect, useRef } from "react";
+import { GitCompare, ChevronDown, Filter, Code2, Type } from "lucide-react";
+import AppButton from "./AppButton";
+import { renderMarkdown } from "../utils/renderUtils";
+import { readImage } from "../services/electronService";
+
+/**
+ * Parse the internal note format into { header, rawNotes, cleansed } sections.
+ * Mirrors parseVersionDocumentContent in DocumentDetail.
+ */
+function parseNoteContent(value, fallback = {}) {
+ const lines = String(value || "").split(/\r?\n/);
+ const rawIndex = lines.findIndex((l) => l.trim().toLowerCase() === "# rawnotes");
+ const cleansedIndex = lines.findIndex((l) => l.trim().toLowerCase() === "# cleansed");
+
+ if (rawIndex === -1 && cleansedIndex === -1) {
+ return {
+ header: fallback.header || "",
+ rawNotes: fallback.rawNotes || "",
+ cleansed: String(value || "").trim(),
+ };
+ }
+
+ const firstIdx = Math.min(
+ rawIndex === -1 ? Infinity : rawIndex,
+ cleansedIndex === -1 ? Infinity : cleansedIndex
+ );
+ const header = lines.slice(0, firstIdx).join("\n").trim();
+ const rawEnd = cleansedIndex > rawIndex && rawIndex !== -1 ? cleansedIndex : lines.length;
+
+ return {
+ header,
+ rawNotes: rawIndex === -1 ? (fallback.rawNotes || "") : lines.slice(rawIndex + 1, rawEnd).join("\n").trim(),
+ cleansed: cleansedIndex === -1 ? (fallback.cleansed || "") : lines.slice(cleansedIndex + 1).join("\n").trim(),
+ };
+}
+
+/**
+ * Compute word-level diff tokens between two strings.
+ * Returns an array of { text, status: "same"|"added"|"removed" }.
+ */
+function diffWords(a, b) {
+ const wordsA = String(a || "").split(/(\s+)/);
+ const wordsB = String(b || "").split(/(\s+)/);
+
+ // Simple LCS-based word diff
+ const m = wordsA.length;
+ const n = wordsB.length;
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
+
+ for (let i = 1; i <= m; i++) {
+ for (let j = 1; j <= n; j++) {
+ if (wordsA[i - 1] === wordsB[j - 1]) {
+ dp[i][j] = dp[i - 1][j - 1] + 1;
+ } else {
+ dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
+ }
+ }
+ }
+
+ const tokens = [];
+ let i = m;
+ let j = n;
+
+ while (i > 0 || j > 0) {
+ if (i > 0 && j > 0 && wordsA[i - 1] === wordsB[j - 1]) {
+ tokens.unshift({ text: wordsA[i - 1], status: "same" });
+ i--;
+ j--;
+ } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
+ tokens.unshift({ text: wordsB[j - 1], status: "added" });
+ j--;
+ } else {
+ tokens.unshift({ text: wordsA[i - 1], status: "removed" });
+ i--;
+ }
+ }
+
+ return tokens;
+}
+
+/**
+ * Build line-level diff rows between two texts.
+ */
+function buildLineDiff(latestText, previousText) {
+ const latest = String(latestText || "").replace(/\r\n/g, "\n").split("\n");
+ const previous = String(previousText || "").replace(/\r\n/g, "\n").split("\n");
+ const max = Math.max(latest.length, previous.length);
+ const rows = [];
+
+ for (let i = 0; i < max; i++) {
+ const l = latest[i] ?? null;
+ const p = previous[i] ?? null;
+
+ let status = "same";
+ if (p === null) status = "added";
+ else if (l === null) status = "removed";
+ else if (l !== p) status = "changed";
+
+ rows.push({ index: i, latest: l, previous: p, status });
+ }
+
+ return rows;
+}
+
+const cleanMarkdown = (text) => {
+ if (!text) return "";
+ return String(text).replace(/(!\[[^\]]*\]\([^)]+\))\{[^}]*\}/g, "$1");
+};
+
+function DiffSection({ title, latestText, previousText, showOnlyChanges, isCodeView }) {
+ const rows = buildLineDiff(latestText, previousText);
+ const visible = showOnlyChanges ? rows.filter((r) => r.status !== "same") : rows;
+
+ if (!latestText && !previousText) return null;
+
+ return (
+
+
{title}
+
+ {visible.length === 0 ? (
+
No changes in this section.
+ ) : visible.map((row, idx) => {
+ if (row.status === "changed") {
+ const hasImageOrDiagram = (() => {
+ const p = String(row.previous || "");
+ const l = String(row.latest || "");
+ const check = (str) => (str.includes(") || str.includes("excali-diagrams/") || str.includes("data-diagram-id");
+ return check(p) || check(l);
+ })();
+
+ if (hasImageOrDiagram) {
+ return (
+
+
+ {row.index + 1}
+ {isCodeView ? (
+ {row.previous}
+ ) : (
+
+ )}
+
+
+ {row.index + 1}
+ {isCodeView ? (
+ {row.latest}
+ ) : (
+
+ )}
+
+
+ );
+ }
+
+ const tokens = diffWords(row.previous ?? "", row.latest ?? "");
+ return (
+
+ {row.index + 1}
+ {isCodeView ? (
+
+ {tokens.map((token, ti) => (
+ token.status === "same"
+ ? {token.text}
+ : token.status === "added"
+ ? {token.text}
+ : {token.text}
+ ))}
+
+ ) : (
+
+ {tokens.map((token, ti) => {
+ const cleanedToken = cleanMarkdown(token.text);
+ if (token.status === "same") {
+ return ;
+ }
+ if (token.status === "added") {
+ return ;
+ }
+ return ;
+ })}
+
+ )}
+
+ );
+ }
+
+ if (row.status === "added") {
+ const isEmpty = !String(row.latest || "").trim();
+ return (
+
+ {row.index + 1}
+ {isCodeView ? (
+ {row.latest}
+ ) : (
+
+ )}
+
+ );
+ }
+
+ if (row.status === "removed") {
+ const isEmpty = !String(row.previous || "").trim();
+ return (
+
+ {row.previous !== null ? row.index + 1 : ""}
+ {isCodeView ? (
+ {row.previous}
+ ) : (
+
+ )}
+
+ );
+ }
+
+ const isEmpty = !String(row.latest || "").trim() && !String(row.previous || "").trim();
+ return (
+
+ {row.index + 1}
+ {isCodeView ? (
+ {row.latest}
+ ) : (
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
+
+/**
+ * GitDiffViewer — renders a human-readable diff between two note versions.
+ *
+ * Sections are labeled "Quick Notes" (rawNotes) and "Formal Notes" (cleansed).
+ * Never exposes raw `# RawNotes` / `# Cleansed` markers.
+ *
+ * Props:
+ * latestContent — string: raw file content of the newer version
+ * previousContent — string: raw file content of the older version
+ * fromLabel — string: label for the older version (e.g. commit hash + date)
+ * toLabel — string: label for the newer version
+ * loading — bool
+ * error — string|null
+ */
+export function GitDiffViewer({
+ latestContent,
+ previousContent,
+ fromLabel = "Previous",
+ toLabel = "Current",
+ loading = false,
+ error = null,
+ basePath = null,
+}) {
+ const [showOnlyChanges, setShowOnlyChanges] = useState(false);
+ const [activeSection, setActiveSection] = useState("quick");
+ const [isCodeView, setIsCodeView] = useState(false);
+ const containerRef = useRef(null);
+
+ const latest = parseNoteContent(latestContent);
+ const previous = parseNoteContent(previousContent);
+
+ useEffect(() => {
+ if (isCodeView || !basePath || !containerRef.current) return;
+ let cancelled = false;
+
+ const resolveImageElement = async (img) => {
+ const src = img.getAttribute("src") || "";
+ const assetPath = img.getAttribute("data-asset-path") || src;
+ if (!assetPath || /^(data:|blob:|https?:)/i.test(assetPath)) return;
+
+ try {
+ let dataUrl = null;
+ const diagramIdMatch = assetPath.match(/excali-diagrams\/([^/]+)/);
+ if (diagramIdMatch && window.notesApi?.readDiagramImage) {
+ const diagramId = diagramIdMatch[1];
+ const response = await window.notesApi.readDiagramImage({
+ documentPath: basePath,
+ diagramId,
+ });
+ dataUrl = response?.success && response?.data ? response.data : null;
+ } else {
+ dataUrl = await readImage(basePath, assetPath, { thumbnail: true });
+ }
+
+ if (!cancelled && dataUrl) {
+ img.setAttribute("data-asset-path", assetPath);
+ img.src = dataUrl;
+ }
+ } catch (err) {
+ // Fall back gracefully
+ }
+ };
+
+ const images = Array.from(containerRef.current.querySelectorAll("img"));
+ images.forEach(resolveImageElement);
+
+ const observer = new MutationObserver((mutations) => {
+ mutations.forEach((mutation) => {
+ mutation.addedNodes.forEach((node) => {
+ if (!(node instanceof HTMLElement)) return;
+ if (node.tagName === "IMG") {
+ void resolveImageElement(node);
+ } else {
+ node.querySelectorAll("img").forEach(resolveImageElement);
+ }
+ });
+ });
+ });
+
+ observer.observe(containerRef.current, { childList: true, subtree: true });
+
+ return () => {
+ cancelled = true;
+ observer.disconnect();
+ };
+ }, [basePath, isCodeView, activeSection, latestContent, previousContent]);
+
+ if (loading) {
+ return (
+
+
+ Loading diff…
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ {error}
+
+ );
+ }
+
+ if (!latestContent && !previousContent) {
+ return (
+
+
+
Select two commits to compare.
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {[
+ { key: "quick", label: "Quick Notes" },
+ { key: "formal", label: "Formal Notes" },
+ ].map(({ key, label }) => (
+ setActiveSection(key)}
+ aria-pressed={activeSection === key}
+ >
+ {label}
+
+ ))}
+
+
+
+
setIsCodeView((v) => !v)}
+ aria-pressed={isCodeView}
+ data-tooltip={isCodeView ? "Switch to Preview mode" : "Switch to Code view"}
+ >
+ {isCodeView ? : }
+ {isCodeView ? "Preview mode" : "Code view"}
+
+
+
setShowOnlyChanges((v) => !v)}
+ aria-pressed={showOnlyChanges}
+ data-tooltip="Toggle between showing all lines and only changed lines"
+ >
+
+ {showOnlyChanges ? "Show all" : "Changes only"}
+
+
+
+
+
+
+ {activeSection === "quick" && (
+
+ )}
+
+ {activeSection === "formal" && (
+
+ )}
+
+
+ );
+}
+
+export default GitDiffViewer;
diff --git a/src/components/GitIntegration.integration.test.jsx b/src/components/GitIntegration.integration.test.jsx
new file mode 100644
index 0000000..609f123
--- /dev/null
+++ b/src/components/GitIntegration.integration.test.jsx
@@ -0,0 +1,186 @@
+// @vitest-environment jsdom
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { GitStatusBar } from "./GitStatusBar";
+import { GitCommitDialog } from "./GitCommitDialog";
+
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+afterEach(() => {
+ document.body.innerHTML = "";
+});
+
+describe("GitStatusBar", () => {
+ it("renders loading state", () => {
+ const host = document.createElement("div");
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ act(() => {
+ root.render( );
+ });
+
+ const button = host.querySelector("button");
+ expect(button).toBeTruthy();
+ expect(button.textContent).toContain("Git…");
+ act(() => {
+ root.unmount();
+ });
+ host.remove();
+ });
+
+ it("renders non-repo warn state", () => {
+ const host = document.createElement("div");
+ document.body.appendChild(host);
+ const root = createRoot(host);
+ const onClick = vi.fn();
+
+ act(() => {
+ root.render(
+
+ );
+ });
+
+ const button = host.querySelector("button");
+ expect(button).toBeTruthy();
+ expect(button.textContent).toContain("No repo");
+ expect(button.classList.contains("git-status-bar--warn")).toBe(true);
+
+ act(() => {
+ button.click();
+ });
+ expect(onClick).toHaveBeenCalledTimes(1);
+
+ act(() => {
+ root.unmount();
+ });
+ host.remove();
+ });
+
+ it("renders clean branch state", () => {
+ const host = document.createElement("div");
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ act(() => {
+ root.render(
+
+ );
+ });
+
+ const button = host.querySelector("button");
+ expect(button).toBeTruthy();
+ expect(button.textContent).toContain("main");
+ expect(button.classList.contains("git-status-bar--clean")).toBe(true);
+ expect(host.querySelector(".git-status-bar__badge")).toBeFalsy();
+
+ act(() => {
+ root.unmount();
+ });
+ host.remove();
+ });
+
+ it("renders pending count state", () => {
+ const host = document.createElement("div");
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ act(() => {
+ root.render(
+
+ );
+ });
+
+ const button = host.querySelector("button");
+ expect(button).toBeTruthy();
+ expect(button.textContent).toContain("feature-branch");
+ expect(button.classList.contains("git-status-bar--pending")).toBe(true);
+
+ const badge = host.querySelector(".git-status-bar__badge");
+ expect(badge).toBeTruthy();
+ expect(badge.textContent).toBe("5");
+
+ act(() => {
+ root.unmount();
+ });
+ host.remove();
+ });
+});
+
+describe("GitCommitDialog", () => {
+ it("allows typing a commit message and triggers commit", async () => {
+ const host = document.createElement("div");
+ document.body.appendChild(host);
+ const root = createRoot(host);
+ const onCommit = vi.fn(() => Promise.resolve());
+ const onClose = vi.fn();
+
+ const files = [
+ { path: "note1.md", status: "modified" },
+ { path: "note2.md", status: "added" }
+ ];
+
+ act(() => {
+ root.render(
+
+ );
+ });
+
+ // Check that files are rendered
+ const fileLabels = host.querySelectorAll(".git-commit-dialog__file-path");
+ expect(fileLabels).toHaveLength(2);
+ expect(fileLabels[0].textContent).toBe("note1.md");
+
+ // Message input
+ const textarea = host.querySelector("textarea");
+ expect(textarea).toBeTruthy();
+
+ act(() => {
+ const nativeValueSetter = Object.getOwnPropertyDescriptor(
+ HTMLTextAreaElement.prototype,
+ "value"
+ ).set;
+ nativeValueSetter.call(textarea, "feat: add new notes");
+ textarea.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+
+ // Locate primary submit button
+ const buttons = host.querySelectorAll("button");
+ let commitBtn = null;
+ buttons.forEach((btn) => {
+ if (btn.textContent.includes("Commit")) {
+ commitBtn = btn;
+ }
+ });
+ expect(commitBtn).toBeTruthy();
+
+ // Trigger commit
+ await act(async () => {
+ commitBtn.click();
+ });
+
+ expect(onCommit).toHaveBeenCalledWith({
+ message: "feat: add new notes",
+ filePaths: ["note1.md", "note2.md"]
+ });
+
+ act(() => {
+ root.unmount();
+ });
+ host.remove();
+ });
+});
diff --git a/src/components/GitNoteHistoryPanel.jsx b/src/components/GitNoteHistoryPanel.jsx
new file mode 100644
index 0000000..b3d3c25
--- /dev/null
+++ b/src/components/GitNoteHistoryPanel.jsx
@@ -0,0 +1,271 @@
+import { useState, useEffect, useCallback } from "react";
+import { GitBranch, GitCommit, X, RotateCcw, GitCompare } from "lucide-react";
+import OverlayDialog from "./OverlayDialog";
+import AppButton from "./AppButton";
+import { GitCommitTimeline } from "./GitCommitTimeline";
+import { GitDiffViewer } from "./GitDiffViewer";
+import {
+ gitGetLog,
+ gitGetFileAtCommit,
+ gitRestoreFileAtCommit,
+ gitGetCommitFiles,
+} from "../services/electronService";
+
+/**
+ * GitNoteHistoryPanel — replaces the legacy version history popover in DocumentDetail.
+ * Shows the git commit history for the current note (filtered by filePath).
+ *
+ * Props:
+ * open — bool
+ * onClose — () => void
+ * filePath — string: absolute path to the current note
+ * workspacePath — string: workspace root (or repo root)
+ * branch — string: current branch name
+ * onNotify — (message, type) => void
+ * onRestored — () => void: called after successful restore (to reload the document)
+ */
+export function GitNoteHistoryPanel({
+ open,
+ onClose,
+ filePath,
+ workspacePath,
+ branch = "",
+ onNotify,
+ onRestored,
+}) {
+ const [commits, setCommits] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ // Diff / compare state
+ const [diffOpen, setDiffOpen] = useState(false);
+ const [compareA, setCompareA] = useState(null); // older commit
+ const [compareB, setCompareB] = useState(null); // newer commit
+ const [diffLoading, setDiffLoading] = useState(false);
+ const [diffError, setDiffError] = useState(null);
+ const [contentA, setContentA] = useState("");
+ const [contentB, setContentB] = useState("");
+
+ // Restore state
+ const [restoring, setRestoring] = useState(false);
+
+ const loadHistory = useCallback(async () => {
+ if (!filePath || !workspacePath) return;
+ setLoading(true);
+ setError(null);
+ try {
+ const result = await gitGetLog({ workspacePath, filePath, limit: 200 });
+ if (result?.ok) {
+ // Enrich commits with their changed files
+ const enriched = await Promise.all(
+ (result.data || []).map(async (c) => {
+ try {
+ const fileResult = await gitGetCommitFiles({ workspacePath, commitHash: c.hash });
+ return { ...c, files: fileResult?.ok ? fileResult.data : [] };
+ } catch {
+ return c;
+ }
+ })
+ );
+ setCommits(enriched);
+ } else {
+ setError(result?.error || "Failed to load history.");
+ }
+ } catch (err) {
+ setError(err?.message || "Failed to load history.");
+ } finally {
+ setLoading(false);
+ }
+ }, [filePath, workspacePath]);
+
+ useEffect(() => {
+ if (open) {
+ loadHistory();
+ setDiffOpen(false);
+ setCompareA(null);
+ setCompareB(null);
+ }
+ }, [open, loadHistory]);
+
+ async function handleCompare(commit) {
+ // If compareA not set, set it; otherwise set compareB and open diff
+ if (!compareA) {
+ setCompareA(commit);
+ onNotify?.("Select another commit to compare with.", "info");
+ return;
+ }
+
+ if (compareA.hash === commit.hash) {
+ setCompareA(null);
+ return;
+ }
+
+ // Determine older/newer by index in commits array
+ const idxA = commits.findIndex((c) => c.hash === compareA.hash);
+ const idxB = commits.findIndex((c) => c.hash === commit.hash);
+
+ const older = idxA > idxB ? compareA : commit; // higher index = older
+ const newer = idxA > idxB ? commit : compareA;
+
+ setCompareA(older);
+ setCompareB(newer);
+ setDiffOpen(true);
+ setDiffLoading(true);
+ setDiffError(null);
+ setContentA("");
+ setContentB("");
+
+ try {
+ const [resultA, resultB] = await Promise.all([
+ gitGetFileAtCommit({ workspacePath, commitHash: older.hash, filePath }),
+ gitGetFileAtCommit({ workspacePath, commitHash: newer.hash, filePath }),
+ ]);
+
+ if (!resultA?.ok) throw new Error(resultA?.error || "Failed to load version.");
+ if (!resultB?.ok) throw new Error(resultB?.error || "Failed to load version.");
+
+ setContentA(resultA.data);
+ setContentB(resultB.data);
+ } catch (err) {
+ setDiffError(err?.message || "Failed to load diff.");
+ } finally {
+ setDiffLoading(false);
+ }
+ }
+
+ async function handleRestore(commit) {
+ if (!filePath || !workspacePath) return;
+ setRestoring(true);
+
+ try {
+ const result = await gitRestoreFileAtCommit({
+ workspacePath,
+ commitHash: commit.hash,
+ filePath,
+ });
+
+ if (!result?.ok) {
+ onNotify?.(result?.error || "Restore failed.", "error");
+ return;
+ }
+
+ onNotify?.(`Restored to ${commit.shortHash}: "${commit.message}"`, "success");
+ onRestored?.();
+ onClose?.();
+ } catch (err) {
+ onNotify?.(err?.message || "Restore failed.", "error");
+ } finally {
+ setRestoring(false);
+ }
+ }
+
+ function closeDiff() {
+ setDiffOpen(false);
+ setCompareA(null);
+ setCompareB(null);
+ setContentA("");
+ setContentB("");
+ }
+
+ const fileName = filePath ? filePath.split(/[\\/]/).pop() : "Note";
+ const commitCount = commits.length;
+
+ return (
+
+
+
+
+
+ History
+ {fileName}
+
+
+
+
+ {branch && (
+
+
+ {branch}
+
+ )}
+ {!loading && commitCount > 0 && (
+
+ {commitCount} commit{commitCount === 1 ? "" : "s"}
+
+ )}
+
+
+
+
+
+
+
+ {compareA && !diffOpen && (
+
+
+
+ {compareA.shortHash} selected — click another commit to compare.
+
+ setCompareA(null)}
+ >
+ Cancel
+
+
+ )}
+
+
+ {diffOpen ? (
+
+
+
+ ← Back to History
+
+
+
+
+ ) : (
+
10}
+ />
+ )}
+
+
+ {restoring && (
+
+ Restoring…
+
+ )}
+
+ );
+}
+
+export default GitNoteHistoryPanel;
diff --git a/src/components/GitStatusBar.jsx b/src/components/GitStatusBar.jsx
new file mode 100644
index 0000000..6dd4263
--- /dev/null
+++ b/src/components/GitStatusBar.jsx
@@ -0,0 +1,80 @@
+import { GitBranch, GitCommit } from "lucide-react";
+
+/**
+ * GitStatusBar — shown in the bottom terminal-status-bar right section.
+ * Replaces the three legacy git pills (branch span, gitignore span, toggle button).
+ * Clicking opens the Version Control page.
+ */
+export function GitStatusBar({ gitState, onClick }) {
+ const { gitAvailable, isRepo, branch, pendingCount, loading } = gitState || {};
+
+ if (loading) {
+ return (
+
+
+ Git…
+
+ );
+ }
+
+ if (!gitAvailable) {
+ return (
+
+
+ Git not found
+
+ );
+ }
+
+ if (!isRepo) {
+ return (
+
+
+ No repo
+
+ );
+ }
+
+ const hasChanges = Number(pendingCount) > 0;
+
+ return (
+
+
+ {branch || "unknown"}
+ {hasChanges && (
+
+ {pendingCount}
+
+ )}
+
+ );
+}
+
+export default GitStatusBar;
diff --git a/src/components/GitVersionControlPage.jsx b/src/components/GitVersionControlPage.jsx
new file mode 100644
index 0000000..d3330c1
--- /dev/null
+++ b/src/components/GitVersionControlPage.jsx
@@ -0,0 +1,1350 @@
+import { useState, useEffect, useCallback, useMemo } from "react";
+import {
+ GitBranch,
+ GitCommit,
+ GitCompare,
+ GitMerge,
+ Tag,
+ Package,
+ Cloud,
+ Settings,
+ ChevronLeft,
+ RefreshCw,
+ Plus,
+ Trash2,
+ Check,
+ X,
+ AlertTriangle,
+ ExternalLink,
+ RotateCcw,
+ Upload,
+ Download,
+ CornerDownLeft,
+ Layers,
+} from "lucide-react";
+import AppButton from "./AppButton";
+import AppInput from "./AppInput";
+import AppIconButton from "./AppIconButton";
+import { GitCommitTimeline } from "./GitCommitTimeline";
+import { GitDiffViewer } from "./GitDiffViewer";
+import { GitCommitDialog } from "./GitCommitDialog";
+import OverlayDialog from "./OverlayDialog";
+import {
+ gitDetect,
+ gitGetRepoInfo,
+ gitInitRepo,
+ gitGetStatus,
+ gitGetLog,
+ gitCommit,
+ gitGetFileAtCommit,
+ gitListBranches,
+ gitCreateBranch,
+ gitRenameBranch,
+ gitDeleteBranch,
+ gitSwitchBranch,
+ gitMergeBranch,
+ gitListTags,
+ gitCreateTag,
+ gitDeleteTag,
+ gitStashList,
+ gitStashPush,
+ gitStashPop,
+ gitStashDrop,
+ gitListRemotes,
+ gitAddRemote,
+ gitRemoveRemote,
+ gitPush,
+ gitPull,
+ gitFetch,
+ gitGetWorkspaceStats,
+ gitGetCommitFiles,
+} from "../services/electronService";
+
+// ── Tab IDs ──────────────────────────────────────────────────────────────────
+
+const TABS = [
+ { id: "status", label: "Status", icon: Check },
+ { id: "history", label: "History", icon: GitCommit },
+ { id: "compare", label: "Compare", icon: GitCompare },
+ { id: "branches", label: "Branches", icon: GitBranch },
+ { id: "tags", label: "Tags", icon: Tag },
+ { id: "stashes", label: "Stashes", icon: Layers },
+ { id: "remotes", label: "Remotes", icon: Cloud },
+ { id: "settings", label: "Settings", icon: Settings },
+];
+
+// ── Empty states ──────────────────────────────────────────────────────────────
+
+function NoGitState({ onInstallLink }) {
+ return (
+
+
+
Git not detected
+
+ Git is not installed or not found on your system PATH.
+ Version control requires Git to be installed.
+
+
window.open("https://git-scm.com/download/win", "_blank")}
+ className="git-vc-empty__action"
+ >
+
+ Install Git for Windows
+
+
+ );
+}
+
+function NoRepoState({ workspacePath, onInit, initializing }) {
+ return (
+
+
+
Not a Git repository
+
+ This workspace is not initialized as a Git repository.
+ Initialize it to start tracking changes with version control.
+
+
+
+ {initializing ? "Initializing…" : "Initialize Repository"}
+
+
+ );
+}
+
+// ── Status Tab ────────────────────────────────────────────────────────────────
+
+function StatusTab({ status, workspacePath, onRefresh, onNotify, onCommitSuccess }) {
+ const [commitDialogOpen, setCommitDialogOpen] = useState(false);
+ const { files = [], branch = "", ahead = 0, behind = 0 } = status || {};
+
+ const modified = files.filter((f) => f.status !== "untracked");
+ const untracked = files.filter((f) => f.status === "untracked");
+
+ async function handleCommit(payload) {
+ const result = await gitCommit({ workspacePath, ...payload });
+ if (!result?.ok) throw new Error(result?.error || "Commit failed.");
+ onNotify?.("Committed successfully.", "success");
+ onCommitSuccess?.();
+ onRefresh?.();
+ }
+
+ return (
+
+
+
+
+ {branch || "unknown"}
+ {(ahead > 0 || behind > 0) && (
+
+ {ahead > 0 && {ahead}↑ }
+ {behind > 0 && {behind}↓ }
+
+ )}
+
+
+
+ Refresh
+
+
+
+ {files.length === 0 ? (
+
+
+
Working tree is clean. No changes to commit.
+
+ ) : (
+
+ {modified.length > 0 && (
+
+
Changes ({modified.length})
+
+ {modified.map((f) => (
+
+ {f.status || "M"}
+ {f.path}
+
+ ))}
+
+
+ )}
+
+ {untracked.length > 0 && (
+
+
Untracked ({untracked.length})
+
+ {untracked.map((f) => (
+
+ ?
+ {f.path}
+
+ ))}
+
+
+ )}
+
+
+ )}
+
+
setCommitDialogOpen(false)}
+ onCommit={handleCommit}
+ stagedFiles={files}
+ workspacePath={workspacePath}
+ />
+
+ );
+}
+
+// ── History Tab ───────────────────────────────────────────────────────────────
+
+function HistoryTab({ commits, loading, error, workspacePath, onNotify, onRefresh, onCreateTag }) {
+ async function handleRestore(commit) {
+ onNotify?.(`Restore from History tab is not supported here — use the note's History button.`, "info");
+ }
+
+ async function handleCompare(commit) {
+ onNotify?.("Switch to the Compare tab to compare commits.", "info");
+ }
+
+ return (
+
+
+
+ );
+}
+
+// ── Compare Tab ───────────────────────────────────────────────────────────────
+
+function CompareTab({ commits, workspacePath, currentFilePath, documents = [], repoRoot }) {
+ const [hashA, setHashA] = useState("");
+ const [hashB, setHashB] = useState("");
+ const [filePathFilter, setFilePathFilter] = useState("");
+ const [fileCommits, setFileCommits] = useState([]);
+ const [loadingCommits, setLoadingCommits] = useState(false);
+ const [contentA, setContentA] = useState("");
+ const [contentB, setContentB] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [compared, setCompared] = useState(false);
+
+ // List of workspace markdown documents relative to repoRoot
+ const workspaceFiles = useMemo(() => {
+ const root = (repoRoot || workspacePath).replace(/\\/g, "/").replace(/\/$/, "");
+ return documents
+ .filter((doc) => doc.entryType === "file" && doc.filePath?.endsWith(".md"))
+ .map((doc) => {
+ const target = doc.filePath.replace(/\\/g, "/");
+ const rel = target.startsWith(root)
+ ? target.slice(root.length).replace(/^\//, "")
+ : target;
+ return {
+ absolutePath: doc.filePath,
+ relativePath: rel,
+ };
+ })
+ .sort((a, b) => a.relativePath.localeCompare(b.relativePath));
+ }, [documents, repoRoot, workspacePath]);
+
+ // Set initial selected file if currentFilePath is provided
+ useEffect(() => {
+ if (currentFilePath && workspaceFiles.length > 0) {
+ const found = workspaceFiles.find(
+ (f) => f.absolutePath.toLowerCase() === currentFilePath.toLowerCase()
+ );
+ if (found) {
+ setFilePathFilter(found.absolutePath);
+ } else {
+ setFilePathFilter(workspaceFiles[0].absolutePath);
+ }
+ } else if (workspaceFiles.length > 0 && !filePathFilter) {
+ setFilePathFilter(workspaceFiles[0].absolutePath);
+ }
+ }, [currentFilePath, workspaceFiles]);
+
+ // Load commits when selected file changes
+ useEffect(() => {
+ if (!filePathFilter) {
+ setFileCommits([]);
+ return;
+ }
+
+ let active = true;
+ setLoadingCommits(true);
+ setError(null);
+ setCompared(false);
+
+ gitGetLog({ workspacePath, filePath: filePathFilter, limit: 100 })
+ .then((res) => {
+ if (!active) return;
+ if (res?.ok) {
+ const list = res.data || [];
+ setFileCommits(list);
+ if (list.length > 0) {
+ setHashA(list[0]?.hash || "");
+ setHashB("WORKING");
+ } else {
+ setHashA("");
+ setHashB("");
+ }
+ } else {
+ setFileCommits([]);
+ setHashA("");
+ setHashB("");
+ }
+ })
+ .catch((err) => {
+ if (!active) return;
+ setError(err?.message || "Failed to load file history.");
+ })
+ .finally(() => {
+ if (active) setLoadingCommits(false);
+ });
+
+ return () => {
+ active = false;
+ };
+ }, [filePathFilter, workspacePath]);
+
+ async function handleCompare() {
+ if (!hashA || !hashB || !filePathFilter.trim()) return;
+ setLoading(true);
+ setError(null);
+ setCompared(false);
+
+ try {
+ const fp = filePathFilter.trim();
+ const [rA, rB] = await Promise.all([
+ gitGetFileAtCommit({ workspacePath, commitHash: hashA, filePath: fp }),
+ gitGetFileAtCommit({ workspacePath, commitHash: hashB, filePath: fp }),
+ ]);
+
+ if (!rA?.ok) throw new Error(rA?.error || "Failed to load version A.");
+ if (!rB?.ok) throw new Error(rB?.error || "Failed to load version B.");
+
+ setContentA(rA.data);
+ setContentB(rB.data);
+ setCompared(true);
+ } catch (err) {
+ setError(err?.message || "Failed to compare.");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+
+
+ Note
+ setFilePathFilter(e.target.value)}
+ >
+ {workspaceFiles.map((file) => (
+
+ {file.relativePath}
+
+ ))}
+
+
+
+
+ From (older)
+ setHashA(e.target.value)}
+ disabled={loadingCommits || fileCommits.length === 0}
+ >
+ {loadingCommits ? (
+ Loading history...
+ ) : fileCommits.length === 0 ? (
+ No history for this file
+ ) : (
+ <>
+ Working Directory (Uncommitted changes)
+ {fileCommits.map((c) => (
+
+ {c.shortHash} — {c.message?.slice(0, 50)}
+
+ ))}
+ >
+ )}
+
+
+
+
→
+
+
+ To (newer)
+ setHashB(e.target.value)}
+ disabled={loadingCommits || fileCommits.length === 0}
+ >
+ {loadingCommits ? (
+ Loading history...
+ ) : fileCommits.length === 0 ? (
+ No history for this file
+ ) : (
+ <>
+ Working Directory (Uncommitted changes)
+ {fileCommits.map((c) => (
+
+ {c.shortHash} — {c.message?.slice(0, 50)}
+
+ ))}
+ >
+ )}
+
+
+
+
+
+ {loading ? "Comparing…" : "Compare"}
+
+
+ {error &&
{error}
}
+
+
+ {compared && (
+
+ )}
+
+ );
+}
+
+// ── Branches Tab ──────────────────────────────────────────────────────────────
+
+function BranchesTab({ workspacePath, onNotify, onRefresh, currentBranch }) {
+ const [branches, setBranches] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [newName, setNewName] = useState("");
+ const [creating, setCreating] = useState(false);
+ const [error, setError] = useState(null);
+
+ const loadBranches = useCallback(async () => {
+ setLoading(true);
+ try {
+ const result = await gitListBranches(workspacePath);
+ if (result?.ok) setBranches(result.data?.branches || []);
+ else setError(result?.error);
+ } catch (err) {
+ setError(err?.message);
+ } finally {
+ setLoading(false);
+ }
+ }, [workspacePath]);
+
+ useEffect(() => { loadBranches(); }, [loadBranches]);
+
+ async function handleCreate() {
+ if (!newName.trim()) return;
+ setCreating(true);
+ const result = await gitCreateBranch({ workspacePath, name: newName.trim() });
+ setCreating(false);
+ if (result?.ok) {
+ onNotify?.(`Branch "${newName.trim()}" created.`, "success");
+ setNewName("");
+ loadBranches();
+ } else {
+ onNotify?.(result?.error || "Failed to create branch.", "error");
+ }
+ }
+
+ async function handleSwitch(name) {
+ const result = await gitSwitchBranch({ workspacePath, name });
+ if (result?.ok) {
+ onNotify?.(`Switched to branch "${name}".`, "success");
+ onRefresh?.();
+ loadBranches();
+ } else {
+ onNotify?.(result?.error || "Failed to switch branch.", "error");
+ }
+ }
+
+ async function handleDelete(name) {
+ if (!window.confirm(`Delete branch "${name}"? This cannot be undone.`)) return;
+ const result = await gitDeleteBranch({ workspacePath, name });
+ if (result?.ok) {
+ onNotify?.(`Branch "${name}" deleted.`, "success");
+ loadBranches();
+ } else {
+ onNotify?.(result?.error || "Failed to delete branch.", "error");
+ }
+ }
+
+ return (
+
+
+
Create branch
+
+
setNewName(e.target.value)}
+ onKeyDown={(e) => { if (e.key === "Enter") handleCreate(); }}
+ placeholder="New branch name"
+ aria-label="New branch name"
+ />
+
+
+ {creating ? "Creating…" : "Create"}
+
+
+
+
+
+
Branches
+ {loading ? (
+
Loading branches…
+ ) : (
+
+ {branches.filter((b) => !b.remote).map((b) => (
+
+
+ {b.name}
+ {b.current && current }
+
+ {!b.current && (
+
handleSwitch(b.name)} data-tooltip={`Switch to ${b.name}`}>
+
+ Switch
+
+ )}
+ {!b.current && (
+
handleDelete(b.name)} data-tooltip={`Delete ${b.name}`}>
+
+
+ )}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+// ── Tags Tab ──────────────────────────────────────────────────────────────────
+
+function TagsTab({ workspacePath, onNotify }) {
+ const [tags, setTags] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [newTagName, setNewTagName] = useState("");
+ const [creating, setCreating] = useState(false);
+
+ const loadTags = useCallback(async () => {
+ setLoading(true);
+ try {
+ const result = await gitListTags(workspacePath);
+ if (result?.ok) setTags(result.data || []);
+ } catch { /* ignore */ } finally {
+ setLoading(false);
+ }
+ }, [workspacePath]);
+
+ useEffect(() => { loadTags(); }, [loadTags]);
+
+ async function handleCreate() {
+ if (!newTagName.trim()) return;
+ setCreating(true);
+ const result = await gitCreateTag({ workspacePath, name: newTagName.trim() });
+ setCreating(false);
+ if (result?.ok) {
+ onNotify?.(`Tag "${newTagName.trim()}" created.`, "success");
+ setNewTagName("");
+ loadTags();
+ } else {
+ onNotify?.(result?.error || "Failed to create tag.", "error");
+ }
+ }
+
+ async function handleDelete(name) {
+ if (!window.confirm(`Delete tag "${name}"?`)) return;
+ const result = await gitDeleteTag({ workspacePath, name });
+ if (result?.ok) {
+ onNotify?.(`Tag "${name}" deleted.`, "success");
+ loadTags();
+ } else {
+ onNotify?.(result?.error || "Failed to delete tag.", "error");
+ }
+ }
+
+ return (
+
+
+
Create tag
+
+
setNewTagName(e.target.value)}
+ onKeyDown={(e) => { if (e.key === "Enter") handleCreate(); }}
+ placeholder="Tag name (e.g. v1.0)"
+ aria-label="New tag name"
+ />
+
+
+ {creating ? "Creating…" : "Tag HEAD"}
+
+
+
+
+
+
Tags ({tags.length})
+ {loading ? (
+
Loading tags…
+ ) : tags.length === 0 ? (
+
No tags yet.
+ ) : (
+
+ {tags.map((t) => (
+
+
+ {t.name}
+ {t.hash && {t.hash.slice(0, 7)} }
+ {t.date && {new Date(t.date).toLocaleDateString()} }
+
+
handleDelete(t.name)}>
+
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+// ── Stashes Tab ───────────────────────────────────────────────────────────────
+
+function StashesTab({ workspacePath, onNotify }) {
+ const [stashes, setStashes] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [stashMsg, setStashMsg] = useState("");
+ const [saving, setSaving] = useState(false);
+
+ const loadStashes = useCallback(async () => {
+ setLoading(true);
+ try {
+ const result = await gitStashList(workspacePath);
+ if (result?.ok) setStashes(result.data || []);
+ } catch { /* ignore */ } finally {
+ setLoading(false);
+ }
+ }, [workspacePath]);
+
+ useEffect(() => { loadStashes(); }, [loadStashes]);
+
+ async function handlePush() {
+ setSaving(true);
+ const result = await gitStashPush({ workspacePath, message: stashMsg.trim() || null });
+ setSaving(false);
+ if (result?.ok) {
+ onNotify?.("Changes stashed.", "success");
+ setStashMsg("");
+ loadStashes();
+ } else {
+ onNotify?.(result?.error || "Stash failed.", "error");
+ }
+ }
+
+ async function handlePop(index) {
+ const result = await gitStashPop({ workspacePath, index });
+ if (result?.ok) {
+ onNotify?.("Stash applied.", "success");
+ loadStashes();
+ } else {
+ onNotify?.(result?.error || "Failed to apply stash.", "error");
+ }
+ }
+
+ async function handleDrop(index, message) {
+ if (!window.confirm(`Drop stash: "${message}"?`)) return;
+ const result = await gitStashDrop({ workspacePath, index });
+ if (result?.ok) {
+ onNotify?.("Stash dropped.", "success");
+ loadStashes();
+ } else {
+ onNotify?.(result?.error || "Failed to drop stash.", "error");
+ }
+ }
+
+ return (
+
+
+
Stash changes
+
+
setStashMsg(e.target.value)}
+ placeholder="Stash message (optional)"
+ aria-label="Stash message"
+ />
+
+
+ {saving ? "Stashing…" : "Stash"}
+
+
+
+
+
+
Stashes ({stashes.length})
+ {loading ? (
+
Loading stashes…
+ ) : stashes.length === 0 ? (
+
No stashes.
+ ) : (
+
+ {stashes.map((s) => (
+
+
+ {s.message}
+ {s.date && {new Date(s.date).toLocaleDateString()} }
+
+
handlePop(s.index)} data-tooltip="Apply and remove stash">
+
+ Pop
+
+
handleDrop(s.index, s.message)}>
+
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+// ── Remotes Tab ───────────────────────────────────────────────────────────────
+
+function RemotesTab({ workspacePath, onNotify, status }) {
+ const [remotes, setRemotes] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [newName, setNewName] = useState("origin");
+ const [newUrl, setNewUrl] = useState("");
+ const [newToken, setNewToken] = useState("");
+ const [adding, setAdding] = useState(false);
+ const [syncBusy, setSyncBusy] = useState(false);
+
+ const loadRemotes = useCallback(async () => {
+ setLoading(true);
+ try {
+ const result = await gitListRemotes(workspacePath);
+ if (result?.ok) setRemotes(result.data || []);
+ } catch { /* ignore */ } finally {
+ setLoading(false);
+ }
+ }, [workspacePath]);
+
+ useEffect(() => { loadRemotes(); }, [loadRemotes]);
+
+ async function handleAdd() {
+ if (!newName.trim() || !newUrl.trim()) return;
+ setAdding(true);
+ const result = await gitAddRemote({ workspacePath, name: newName.trim(), url: newUrl.trim() });
+ setAdding(false);
+ if (result?.ok) {
+ onNotify?.(`Remote "${newName}" added.`, "success");
+ setNewName("origin");
+ setNewUrl("");
+ setNewToken("");
+ loadRemotes();
+ } else {
+ onNotify?.(result?.error || "Failed to add remote.", "error");
+ }
+ }
+
+ async function handleRemove(name) {
+ if (!window.confirm(`Remove remote "${name}"?`)) return;
+ const result = await gitRemoveRemote({ workspacePath, name });
+ if (result?.ok) {
+ onNotify?.(`Remote "${name}" removed.`, "success");
+ loadRemotes();
+ } else {
+ onNotify?.(result?.error || "Failed to remove remote.", "error");
+ }
+ }
+
+ async function handleSync(remote, action) {
+ setSyncBusy(true);
+ const auth = newToken.trim() ? { type: "pat", token: newToken.trim() } : { type: "ssh" };
+ try {
+ let result;
+ if (action === "push") {
+ result = await gitPush({ workspacePath, remote: remote.name, auth });
+ } else if (action === "pull") {
+ result = await gitPull({ workspacePath, remote: remote.name, auth });
+ } else {
+ result = await gitFetch({ workspacePath, remote: remote.name, auth });
+ }
+ if (result?.ok) {
+ onNotify?.(`${action.charAt(0).toUpperCase() + action.slice(1)} to "${remote.name}" succeeded.`, "success");
+ } else {
+ onNotify?.(result?.error || `${action} failed.`, "error");
+ }
+ } catch (err) {
+ onNotify?.(err?.message || `${action} failed.`, "error");
+ } finally {
+ setSyncBusy(false);
+ }
+ }
+
+ const { ahead = 0, behind = 0 } = status || {};
+
+ return (
+
+ {remotes.length > 0 && (
+
+
Sync
+ {(ahead > 0 || behind > 0) && (
+
+ {ahead > 0 && {ahead} commit{ahead === 1 ? "" : "s"} to push }
+ {behind > 0 && {behind} commit{behind === 1 ? "" : "s"} to pull }
+
+ )}
+
+
+
+ Personal Access Token (HTTPS, optional)
+
+
setNewToken(e.target.value)}
+ placeholder="Leave blank for SSH or public repos"
+ aria-label="Personal Access Token"
+ />
+
+ Token is used only for this session and not stored.
+
+
+
+
+ {remotes.map((r) => (
+
+
{r.name}
+
handleSync(r, "push")} disabled={syncBusy}>
+
+ Push
+
+
handleSync(r, "pull")} disabled={syncBusy}>
+
+ Pull
+
+
handleSync(r, "fetch")} disabled={syncBusy}>
+
+ Fetch
+
+
+ ))}
+
+
+ )}
+
+
+
Remotes
+ {loading ? (
+
Loading remotes…
+ ) : remotes.length === 0 ? (
+
No remotes configured.
+ ) : (
+
+ {remotes.map((r) => (
+
+
+ {r.name}
+ {r.fetchUrl}
+
+
handleRemove(r.name)}>
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
Add remote
+
+
setNewName(e.target.value)}
+ placeholder="Remote name (e.g. origin)"
+ aria-label="Remote name"
+ />
+ setNewUrl(e.target.value)}
+ placeholder="Remote URL (HTTPS or SSH)"
+ aria-label="Remote URL"
+ />
+
+
+ {adding ? "Adding…" : "Add Remote"}
+
+
+
+
+ );
+}
+
+
+
+// ── Settings Tab ──────────────────────────────────────────────────────────────
+
+function SettingsTab({ workspacePath }) {
+ const [name, setName] = useState("");
+ const [email, setEmail] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [saved, setSaved] = useState(false);
+
+ return (
+
+
Commit Identity
+
+ Git identity is read from your global git config (~/.gitconfig).
+ Use the integrated terminal to set it:
+
+
+ {`git config --global user.name "Your Name"
+git config --global user.email "you@example.com"`}
+
+
+
+ Gitignore
+
+
+ The "Ignore App Data in Git" toggle is in the Version Control menu bar.
+ It controls whether .notes-app/ is automatically excluded from your repository.
+
+
+ );
+}
+
+// ── Main Page ─────────────────────────────────────────────────────────────────
+
+/**
+ * GitVersionControlPage — full-page version control interface.
+ *
+ * Props:
+ * workspacePath — string
+ * onBack — () => void
+ * onNotify — (message, type) => void
+ * initialTab — string (optional, defaults to "status")
+ * onGitStateChange — ({ branch, pendingCount }) => void — called on refresh for GitStatusBar
+ */
+export function GitVersionControlPage({
+ workspacePath,
+ onBack,
+ onNotify,
+ initialTab = "status",
+ onGitStateChange,
+ currentFilePath = null,
+ documents = [],
+}) {
+ const [activeTab, setActiveTab] = useState(initialTab);
+ useEffect(() => {
+ setActiveTab(initialTab);
+ }, [initialTab]);
+ const [gitAvailable, setGitAvailable] = useState(null); // null = checking
+ const [isRepo, setIsRepo] = useState(null);
+ const [repoRoot, setRepoRoot] = useState(null);
+ const [initializing, setInitializing] = useState(false);
+ const [status, setStatus] = useState(null);
+ const [commits, setCommits] = useState([]);
+ const [commitsLoading, setCommitsLoading] = useState(false);
+ const [commitsError, setCommitsError] = useState(null);
+ const [commitDialogOpen, setCommitDialogOpen] = useState(false);
+ const [tagDialogOpen, setTagDialogOpen] = useState(false);
+ const [tagCommit, setTagCommit] = useState(null);
+ const [tagName, setTagName] = useState("");
+
+ async function handleConfirmTag() {
+ if (!tagCommit || !tagName.trim()) return;
+ const targetPath = repoRoot || workspacePath;
+ const result = await gitCreateTag({
+ workspacePath: targetPath,
+ name: tagName.trim(),
+ commitHash: tagCommit.hash,
+ });
+ if (result?.ok) {
+ onNotify?.(`Tag "${tagName.trim()}" created successfully.`, "success");
+ setTagDialogOpen(false);
+ setTagName("");
+ setTagCommit(null);
+ refreshCommits();
+ } else {
+ onNotify?.(result?.error || "Failed to create tag.", "error");
+ }
+ }
+
+ const refreshStatus = useCallback(async () => {
+ if (!workspacePath) return;
+ try {
+ const result = await gitGetStatus(workspacePath);
+ if (result?.ok) {
+ setStatus(result.data);
+ onGitStateChange?.({
+ branch: result.data.branch,
+ pendingCount: result.data.files.length,
+ repoRoot: result.data.repoRoot,
+ });
+ }
+ } catch { /* ignore */ }
+ }, [workspacePath, onGitStateChange]);
+
+ const refreshCommits = useCallback(async () => {
+ if (!workspacePath) return;
+ setCommitsLoading(true);
+ setCommitsError(null);
+ try {
+ const result = await gitGetLog({ workspacePath, limit: 200 });
+ if (result?.ok) {
+ const enriched = await Promise.all(
+ (result.data || []).map(async (c) => {
+ try {
+ const fileResult = await gitGetCommitFiles({ workspacePath, commitHash: c.hash });
+ return { ...c, files: fileResult?.ok ? fileResult.data : [] };
+ } catch { return c; }
+ })
+ );
+ setCommits(enriched);
+ } else {
+ setCommitsError(result?.error || "Failed to load commits.");
+ }
+ } catch (err) {
+ setCommitsError(err?.message);
+ } finally {
+ setCommitsLoading(false);
+ }
+ }, [workspacePath]);
+
+ const checkGitAndRepo = useCallback(async () => {
+ try {
+ const detection = await gitDetect();
+ if (!detection?.ok || !detection.data.available) {
+ setGitAvailable(false);
+ return;
+ }
+ setGitAvailable(true);
+
+ const repoInfo = await gitGetRepoInfo(workspacePath);
+ if (repoInfo?.ok) {
+ setIsRepo(repoInfo.data.isRepo);
+ setRepoRoot(repoInfo.data.repoRoot);
+
+ if (repoInfo.data.isRepo) {
+ refreshStatus();
+ refreshCommits();
+ }
+ }
+ } catch { /* ignore */ }
+ }, [workspacePath, refreshStatus, refreshCommits]);
+
+ useEffect(() => {
+ checkGitAndRepo();
+ }, [checkGitAndRepo]);
+
+ async function handleInit() {
+ setInitializing(true);
+ try {
+ const result = await gitInitRepo(workspacePath);
+ if (result?.ok) {
+ onNotify?.("Git repository initialized successfully.", "success");
+ checkGitAndRepo();
+ } else {
+ onNotify?.(result?.error || "Initialization failed.", "error");
+ }
+ } catch (err) {
+ onNotify?.(err?.message || "Initialization failed.", "error");
+ } finally {
+ setInitializing(false);
+ }
+ }
+
+ async function handleGlobalCommit(payload) {
+ const result = await gitCommit({ workspacePath, ...payload });
+ if (!result?.ok) throw new Error(result?.error || "Commit failed.");
+ onNotify?.("Committed successfully.", "success");
+ refreshStatus();
+ refreshCommits();
+ }
+
+ function handleRefresh() {
+ refreshStatus();
+ refreshCommits();
+ }
+
+ // Checking state
+ if (gitAvailable === null) {
+ return (
+
+
+
+
+ Notes
+ /
+
+ Version Control
+
+
+
+
+ Checking for Git…
+
+
+ );
+ }
+
+ return (
+
+ {/* Breadcrumb / Header — matched with DocumentDetail page header */}
+
+
+
+ Notes
+ /
+
+ Version Control
+
+
+ {isRepo && status?.branch && (
+
+
+ {status.branch}
+ {status.files?.length > 0 && (
+ {status.files.length}
+ )}
+
+ )}
+
+
+ {isRepo && (
+ <>
+
setCommitDialogOpen(true)}
+ data-tooltip="Commit changes (Ctrl+Shift+K)"
+ aria-label="Commit"
+ >
+
+ Commit
+
+
+
+
+ >
+ )}
+
+
+
+ {/* Empty states */}
+ {!gitAvailable &&
}
+ {gitAvailable && isRepo === false && (
+
+ )}
+
+ {/* Full UI when repo exists */}
+ {gitAvailable && isRepo && (
+
+
+ {/* Tab strip */}
+
+ {TABS.map(({ id, label, icon: Icon }) => (
+ setActiveTab(id)}
+ >
+
+ {label}
+
+ ))}
+
+
+ {/* Tab panels */}
+
+ {activeTab === "status" && (
+
+ )}
+ {activeTab === "history" && (
+ {
+ setTagCommit(commit);
+ setTagName("");
+ setTagDialogOpen(true);
+ }}
+ />
+ )}
+ {activeTab === "compare" && (
+
+ )}
+ {activeTab === "branches" && (
+
+ )}
+ {activeTab === "tags" && (
+
+ )}
+ {activeTab === "stashes" && (
+
+ )}
+ {activeTab === "remotes" && (
+
+ )}
+
+ {activeTab === "settings" && (
+
+ )}
+
+
+
+ )}
+
+ {/* Global commit dialog (from header button or keyboard shortcut) */}
+ {isRepo && (
+
setCommitDialogOpen(false)}
+ onCommit={handleGlobalCommit}
+ stagedFiles={status?.files || []}
+ workspacePath={workspacePath}
+ />
+ )}
+
+ {/* Tag creation dialog */}
+ {tagDialogOpen && tagCommit && (
+ {
+ setTagDialogOpen(false);
+ setTagCommit(null);
+ }}
+ ariaLabel="Tag Commit"
+ >
+
+
Tag Commit
+
+
+
+ Add a tag to commit {tagCommit.shortHash}:
+
+
Tag Name
+
setTagName(e.target.value)}
+ placeholder="e.g. v1.0.0"
+ autoFocus
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ handleConfirmTag();
+ }
+ }}
+ />
+
+
+
{
+ setTagDialogOpen(false);
+ setTagCommit(null);
+ }}>Cancel
+
+ Create Tag
+
+
+
+ )}
+
+ );
+}
+
+export default GitVersionControlPage;
diff --git a/src/components/OnboardingFlow.jsx b/src/components/OnboardingFlow.jsx
index 1f850f1..96c74a8 100644
--- a/src/components/OnboardingFlow.jsx
+++ b/src/components/OnboardingFlow.jsx
@@ -13,7 +13,9 @@ import {
X,
Github,
User,
- ExternalLink
+ ExternalLink,
+ Table,
+ Palette
} from "lucide-react";
import { pickFolder } from "../services/electronService";
import notelyMark from "../assets/branding/notely-mark.png";
@@ -173,31 +175,45 @@ export function OnboardingFlow({
-
+
-
Rich Markdown
-
Edit in raw, split, preview, or web modes with live diagram rendering.
+
Markdown & Inline Tables
+
Format styled text and edit complex tables using a focused visual grid editor.
+
+
+
+
+
+
Excalidraw & Mermaid
+
Sketch quick drawings with Excalidraw, and render diagrams instantly with Mermaid.
-
Revision History
-
Compare versions and restore context from older note revisions.
+
Git Version Control
+
Track history, manage branches, and tag commits directly inside your workspace.
P2P Note Sync
-
Discover, pair, and synchronize notes with peers on your local network.
+
Discover, pair, and synchronize notes securely with peers on your local network.
Workspace AI
-
In-app chat assistance, query search, and semantic graph views.
+
Query semantic search, ask questions, and chat with local context integration.
+
+
+
+
+
+
Tasks & Workspace Exports
+
Track checklists across project files and export workspaces as PDF, HTML, or Zip.
diff --git a/src/hooks/useDocumentEditorActions.js b/src/hooks/useDocumentEditorActions.js
index 79e5c7e..df010d2 100644
--- a/src/hooks/useDocumentEditorActions.js
+++ b/src/hooks/useDocumentEditorActions.js
@@ -71,7 +71,11 @@ export function useDocumentEditorActions({
return;
}
- if (menuAction.action === "manage-versions") {
+ if (
+ menuAction.action === "manage-versions" ||
+ menuAction.action === "git-history" ||
+ menuAction.action === "git-diff-current"
+ ) {
openHistoryVersions();
}
}, [
diff --git a/src/hooks/useDocumentManager.js b/src/hooks/useDocumentManager.js
index 15f64eb..f2f6335 100644
--- a/src/hooks/useDocumentManager.js
+++ b/src/hooks/useDocumentManager.js
@@ -15,7 +15,6 @@ import {
renameDocument as renameDocumentApi,
saveDocument as saveDocumentApi,
setNotesRootSetting,
- getHistory,
} from "../services/electronService";
function normalizePathValue(value) {
@@ -154,7 +153,7 @@ export function useDocumentManager({ notify }) {
if (!options.preserveActiveTab) {
setActiveTab("raw");
}
- setHistory(await getHistory(filePath));
+ setHistory([]);
}
async function saveDocument(options = {}) {
@@ -180,7 +179,7 @@ export function useDocumentManager({ notify }) {
cleansed: saved.cleansed,
})
);
- setHistory(await getHistory(saved.filePath));
+ setHistory([]);
await loadDocumentsData();
if (!silent) {
notify("Note saved.", "success");
@@ -237,7 +236,7 @@ export function useDocumentManager({ notify }) {
cleansed: renamed.cleansed,
})
);
- setHistory(await getHistory(renamed.filePath));
+ setHistory([]);
await loadDocumentsData();
notify("Note renamed.", "success");
return true;
diff --git a/src/services/electronService.js b/src/services/electronService.js
index 54299cc..0c2d587 100644
--- a/src/services/electronService.js
+++ b/src/services/electronService.js
@@ -555,32 +555,6 @@ export async function saveDocument(payload) {
return api.saveDocument(payload);
}
-export async function getHistory(filePath) {
- const api = getNotesApi();
- return api.getHistory(filePath);
-}
-
-export async function restoreHistory(payload) {
- const api = getNotesApi();
- return api.restoreHistory(payload);
-}
-
-export async function readVersion(filePath, versionPath) {
- const api = getNotesApi();
- if (typeof api.readVersion !== "function") {
- throw new Error("Read version action unavailable. Please restart the app.");
- }
- return api.readVersion({ filePath, versionPath });
-}
-
-export async function deleteVersion(filePath, versionPath) {
- const api = getNotesApi();
- if (typeof api.deleteVersion !== "function") {
- throw new Error("Delete version action unavailable. Please restart the app.");
- }
- return api.deleteVersion({ filePath, versionPath });
-}
-
export async function openInEditor(filePath) {
const api = getNotesApi();
const openFn =
@@ -830,3 +804,222 @@ export function onTerminalExit(callback) {
}
return api.onTerminalExit(callback);
}
+
+// ── Git Version Control ────────────────────────────────────────────────────────
+
+function requireGitApi(api, methodName) {
+ if (typeof api[methodName] !== "function") {
+ throw new Error(`Git action '${methodName}' unavailable. Please restart the app.`);
+ }
+}
+
+export async function gitDetect() {
+ const api = getNotesApi();
+ requireGitApi(api, "gitDetect");
+ return api.gitDetect();
+}
+
+export async function gitGetRepoInfo(workspacePath) {
+ const api = getNotesApi();
+ requireGitApi(api, "gitGetRepoInfo");
+ return api.gitGetRepoInfo({ workspacePath });
+}
+
+export async function gitInitRepo(workspacePath) {
+ const api = getNotesApi();
+ requireGitApi(api, "gitInitRepo");
+ return api.gitInitRepo({ workspacePath });
+}
+
+export async function gitGetStatus(workspacePath) {
+ const api = getNotesApi();
+ requireGitApi(api, "gitGetStatus");
+ return api.gitGetStatus({ workspacePath });
+}
+
+export async function gitGetLog({ workspacePath, filePath, limit, skip, branch } = {}) {
+ const api = getNotesApi();
+ requireGitApi(api, "gitGetLog");
+ return api.gitGetLog({ workspacePath, filePath, limit, skip, branch });
+}
+
+export async function gitGetCommitFiles({ workspacePath, commitHash } = {}) {
+ const api = getNotesApi();
+ requireGitApi(api, "gitGetCommitFiles");
+ return api.gitGetCommitFiles({ workspacePath, commitHash });
+}
+
+export async function gitGetFileAtCommit({ workspacePath, commitHash, filePath } = {}) {
+ const api = getNotesApi();
+ requireGitApi(api, "gitGetFileAtCommit");
+ return api.gitGetFileAtCommit({ workspacePath, commitHash, filePath });
+}
+
+export async function gitGetFileDiff({ workspacePath, fromHash, toHash, filePath } = {}) {
+ const api = getNotesApi();
+ requireGitApi(api, "gitGetFileDiff");
+ return api.gitGetFileDiff({ workspacePath, fromHash, toHash, filePath });
+}
+
+export async function gitCommit({ workspacePath, message, filePaths } = {}) {
+ const api = getNotesApi();
+ requireGitApi(api, "gitCommit");
+ return api.gitCommit({ workspacePath, message, filePaths });
+}
+
+export async function gitRestoreFileAtCommit({ workspacePath, commitHash, filePath } = {}) {
+ const api = getNotesApi();
+ requireGitApi(api, "gitRestoreFileAtCommit");
+ return api.gitRestoreFileAtCommit({ workspacePath, commitHash, filePath });
+}
+
+export async function gitListBranches(workspacePath) {
+ const api = getNotesApi();
+ requireGitApi(api, "gitListBranches");
+ return api.gitListBranches({ workspacePath });
+}
+
+export async function gitCreateBranch({ workspacePath, name, from } = {}) {
+ const api = getNotesApi();
+ requireGitApi(api, "gitCreateBranch");
+ return api.gitCreateBranch({ workspacePath, name, from });
+}
+
+export async function gitRenameBranch({ workspacePath, oldName, newName } = {}) {
+ const api = getNotesApi();
+ requireGitApi(api, "gitRenameBranch");
+ return api.gitRenameBranch({ workspacePath, oldName, newName });
+}
+
+export async function gitDeleteBranch({ workspacePath, name, force } = {}) {
+ const api = getNotesApi();
+ requireGitApi(api, "gitDeleteBranch");
+ return api.gitDeleteBranch({ workspacePath, name, force });
+}
+
+export async function gitSwitchBranch({ workspacePath, name } = {}) {
+ const api = getNotesApi();
+ requireGitApi(api, "gitSwitchBranch");
+ return api.gitSwitchBranch({ workspacePath, name });
+}
+
+export async function gitMergeBranch({ workspacePath, from } = {}) {
+ const api = getNotesApi();
+ requireGitApi(api, "gitMergeBranch");
+ return api.gitMergeBranch({ workspacePath, from });
+}
+
+export async function gitListTags(workspacePath) {
+ const api = getNotesApi();
+ requireGitApi(api, "gitListTags");
+ return api.gitListTags({ workspacePath });
+}
+
+export async function gitCreateTag({ workspacePath, name, commitHash, message } = {}) {
+ const api = getNotesApi();
+ requireGitApi(api, "gitCreateTag");
+ return api.gitCreateTag({ workspacePath, name, commitHash, message });
+}
+
+export async function gitDeleteTag({ workspacePath, name } = {}) {
+ const api = getNotesApi();
+ requireGitApi(api, "gitDeleteTag");
+ return api.gitDeleteTag({ workspacePath, name });
+}
+
+export async function gitStashList(workspacePath) {
+ const api = getNotesApi();
+ requireGitApi(api, "gitStashList");
+ return api.gitStashList({ workspacePath });
+}
+
+export async function gitStashPush({ workspacePath, message } = {}) {
+ const api = getNotesApi();
+ requireGitApi(api, "gitStashPush");
+ return api.gitStashPush({ workspacePath, message });
+}
+
+export async function gitStashPop({ workspacePath, index } = {}) {
+ const api = getNotesApi();
+ requireGitApi(api, "gitStashPop");
+ return api.gitStashPop({ workspacePath, index });
+}
+
+export async function gitStashDrop({ workspacePath, index } = {}) {
+ const api = getNotesApi();
+ requireGitApi(api, "gitStashDrop");
+ return api.gitStashDrop({ workspacePath, index });
+}
+
+export async function gitListRemotes(workspacePath) {
+ const api = getNotesApi();
+ requireGitApi(api, "gitListRemotes");
+ return api.gitListRemotes({ workspacePath });
+}
+
+export async function gitAddRemote({ workspacePath, name, url } = {}) {
+ const api = getNotesApi();
+ requireGitApi(api, "gitAddRemote");
+ return api.gitAddRemote({ workspacePath, name, url });
+}
+
+export async function gitRemoveRemote({ workspacePath, name } = {}) {
+ const api = getNotesApi();
+ requireGitApi(api, "gitRemoveRemote");
+ return api.gitRemoveRemote({ workspacePath, name });
+}
+
+export async function gitPush({ workspacePath, remote, branch, auth } = {}) {
+ const api = getNotesApi();
+ requireGitApi(api, "gitPush");
+ return api.gitPush({ workspacePath, remote, branch, auth });
+}
+
+export async function gitPull({ workspacePath, remote, branch, auth } = {}) {
+ const api = getNotesApi();
+ requireGitApi(api, "gitPull");
+ return api.gitPull({ workspacePath, remote, branch, auth });
+}
+
+export async function gitFetch({ workspacePath, remote, auth } = {}) {
+ const api = getNotesApi();
+ requireGitApi(api, "gitFetch");
+ return api.gitFetch({ workspacePath, remote, auth });
+}
+
+export async function gitSearch({ workspacePath, query, type } = {}) {
+ const api = getNotesApi();
+ requireGitApi(api, "gitSearch");
+ return api.gitSearch({ workspacePath, query, type });
+}
+
+export async function gitGetDeletedFiles(workspacePath) {
+ const api = getNotesApi();
+ requireGitApi(api, "gitGetDeletedFiles");
+ return api.gitGetDeletedFiles({ workspacePath });
+}
+
+export async function gitGetWorkspaceStats(workspacePath) {
+ const api = getNotesApi();
+ requireGitApi(api, "gitGetWorkspaceStats");
+ return api.gitGetWorkspaceStats({ workspacePath });
+}
+
+export async function gitMigrateLegacy(workspacePath) {
+ const api = getNotesApi();
+ requireGitApi(api, "gitMigrateLegacy");
+ return api.gitMigrateLegacy({ workspacePath });
+}
+
+export async function gitEnsureManagedGitignore(workspacePath) {
+ const api = getNotesApi();
+ requireGitApi(api, "gitEnsureManagedGitignore");
+ return api.gitEnsureManagedGitignore({ workspacePath });
+}
+
+export async function gitRemoveManagedGitignore(workspacePath) {
+ const api = getNotesApi();
+ requireGitApi(api, "gitRemoveManagedGitignore");
+ return api.gitRemoveManagedGitignore({ workspacePath });
+}
+
diff --git a/src/styles.css b/src/styles.css
index 6fcb633..b136204 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -10031,3 +10031,1358 @@ button {
background: color-mix(in srgb, #ef4444 26%, #23262b 74%);
color: #f87171;
}
+
+/* ── Git Version Control ────────────────────────────────────────────────── */
+
+.git-vc-dialog-card {
+ width: min(980px, calc(100vw - 28px));
+ max-height: calc(100vh - 32px);
+ overflow: hidden;
+}
+
+.git-vc-page {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ background: var(--app-bg);
+ color: var(--app-text);
+ font-family: inherit;
+ overflow: hidden;
+}
+
+.git-vc-page .p2p-tab-shell {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ overflow: hidden;
+ height: 100%;
+}
+
+.git-vc-page .p2p-tab-panel {
+ flex: 1;
+ overflow-y: auto;
+ padding: var(--space-5, 1.25rem);
+ max-height: calc(100vh - 120px);
+}
+
+.git-vc-checking,
+.git-vc-empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ flex: 1;
+ padding: var(--space-8, 2rem);
+ text-align: center;
+}
+
+.git-vc-empty--inline {
+ flex: none;
+ padding: var(--space-6, 1.5rem);
+ border: 1px dashed var(--border-default);
+ border-radius: var(--radius-md, 8px);
+ background: var(--surface-muted);
+ margin: var(--space-4, 1rem) 0;
+}
+
+.git-vc-empty__icon {
+ color: var(--text-muted);
+ margin-bottom: var(--space-4, 1rem);
+}
+
+.git-vc-empty__icon--warn {
+ color: var(--status-warning-text);
+}
+
+.git-vc-empty__icon--success {
+ color: var(--status-success-text);
+}
+
+.git-vc-empty__title {
+ font-size: var(--font-size-heading-lg, 22px);
+ font-weight: 600;
+ margin-bottom: var(--space-2, 0.5rem);
+ color: var(--text-strong);
+}
+
+.git-vc-empty__desc {
+ max-width: 480px;
+ font-size: var(--font-size-body, 14px);
+ color: var(--text-secondary);
+ margin-bottom: var(--space-6, 1.5rem);
+ line-height: 1.5;
+}
+
+.git-vc-content {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ overflow: hidden;
+}
+
+/* Tabs */
+.git-vc-tabs {
+ display: flex;
+ gap: var(--space-1, 0.25rem);
+ padding: 0 var(--space-4, 1rem);
+ border-bottom: 1px solid var(--border-default);
+ background: var(--surface-elevated);
+ overflow-x: auto;
+}
+
+.git-vc-tab {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2, 0.5rem);
+ padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
+ background: transparent;
+ border: none;
+ border-bottom: 2px solid transparent;
+ color: var(--text-muted);
+ font-size: var(--font-size-body-sm, 13px);
+ font-weight: 500;
+ cursor: pointer;
+ white-space: nowrap;
+ transition: all var(--motion-fast, 120ms ease);
+}
+
+.git-vc-tab:hover {
+ color: var(--text-strong);
+ background: var(--surface-accent);
+}
+
+.git-vc-tab--active {
+ color: var(--accent-solid);
+ border-bottom-color: var(--accent-solid);
+ background: var(--surface-accent-strong);
+}
+
+/* Panel */
+.git-vc-panel {
+ flex: 1;
+ overflow-y: auto;
+ padding: var(--space-6, 1.5rem);
+}
+
+/* Status Tab */
+.git-vc-status {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-6, 1.5rem);
+ height: 100%;
+}
+
+.git-vc-status__header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.git-vc-status__branch {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2, 0.5rem);
+ font-size: var(--font-size-body, 14px);
+ color: var(--text-strong);
+}
+
+.git-vc-status__sync {
+ display: flex;
+ gap: var(--space-2, 0.5rem);
+ font-size: var(--font-size-caption, 11px);
+}
+
+.git-vc-status__ahead {
+ color: var(--status-success-text);
+}
+
+.git-vc-status__behind {
+ color: var(--status-warning-text);
+}
+
+.git-vc-status__group {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2, 0.5rem);
+}
+
+.git-vc-status__group-title {
+ font-size: var(--font-size-label, 12px);
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text-muted);
+}
+
+.git-vc-file-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ border: 1px solid var(--border-default);
+ border-radius: var(--radius-md, 8px);
+ background: var(--surface-bg);
+ overflow: hidden;
+}
+
+.git-vc-file-row {
+ display: flex;
+ align-items: center;
+ padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
+ border-bottom: 1px solid var(--border-default);
+ font-size: var(--font-size-body, 14px);
+}
+
+.git-vc-file-row:last-child {
+ border-bottom: none;
+}
+
+.git-vc-file-row__status {
+ font-family: monospace;
+ font-weight: bold;
+ font-size: var(--font-size-label, 12px);
+ width: 24px;
+ display: inline-block;
+}
+
+.git-vc-file-row--modified .git-vc-file-row__status {
+ color: var(--status-warning-text);
+}
+
+.git-vc-file-row--added .git-vc-file-row__status {
+ color: var(--status-success-text);
+}
+
+.git-vc-file-row--deleted .git-vc-file-row__status {
+ color: var(--status-danger-text);
+}
+
+.git-vc-file-row--untracked .git-vc-file-row__status {
+ color: var(--text-muted);
+}
+
+.git-vc-file-row__path {
+ font-family: monospace;
+ color: var(--app-text);
+ word-break: break-all;
+}
+
+.git-vc-status__actions {
+ display: flex;
+ margin-top: var(--space-4, 1rem);
+}
+
+/* Compare Tab */
+.git-vc-compare {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-6, 1.5rem);
+}
+
+.git-vc-compare__controls {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ align-items: flex-end;
+ gap: var(--space-4, 1rem);
+ padding: var(--space-4, 1rem);
+ border: 1px solid var(--border-default);
+ border-radius: var(--radius-md, 8px);
+ background: var(--surface-bg);
+}
+
+.git-vc-compare__picker {
+ flex: 1;
+ min-width: 200px;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2, 0.5rem);
+}
+
+.git-vc-compare__label {
+ font-size: var(--font-size-label, 12px);
+ font-weight: 500;
+ color: var(--text-muted);
+}
+
+.git-vc-compare__input {
+ width: 100%;
+ height: 32px;
+ padding: 0 var(--space-2, 0.5rem);
+ background: var(--surface-muted);
+ border: 1px solid var(--border-default);
+ border-radius: var(--radius-sm, 6px);
+ color: var(--app-text);
+ font-family: inherit;
+ font-size: var(--font-size-body-sm, 13px);
+ cursor: pointer;
+ outline: none;
+}
+
+.git-vc-compare__input:focus {
+ border-color: var(--accent-solid);
+}
+
+.git-vc-compare__arrow {
+ color: var(--text-muted);
+ font-size: var(--font-size-heading-lg, 22px);
+ align-self: flex-end;
+ padding-bottom: 4px;
+}
+
+.git-vc-compare__file-filter {
+ flex: 1.2;
+ min-width: 200px;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2, 0.5rem);
+}
+
+/* Input fields inside Git VC */
+.git-vc-page input[type="text"],
+.git-vc-page input[type="password"] {
+ width: 100%;
+ height: 32px;
+ padding: 0 var(--space-3, 0.75rem);
+ background: var(--surface-muted);
+ border: 1px solid var(--border-default);
+ border-radius: var(--radius-sm, 6px);
+ color: var(--app-text);
+ font-family: inherit;
+ font-size: var(--font-size-body-sm, 13px);
+ outline: none;
+ transition: border-color var(--motion-fast, 120ms ease);
+}
+
+.git-vc-page input[type="text"]:focus,
+.git-vc-page input[type="password"]:focus {
+ border-color: var(--accent-solid);
+}
+
+/* Branches, Tags, Stashes */
+.git-vc-branches,
+.git-vc-tags,
+.git-vc-stashes {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-5, 1.25rem);
+}
+
+.git-vc-section-title {
+ font-size: var(--font-size-heading-md, 18px);
+ font-weight: 600;
+ margin-bottom: var(--space-3, 0.75rem);
+ color: var(--text-strong);
+}
+
+.git-vc-branches__create-row,
+.git-vc-tags__create-row,
+.git-vc-stashes__push-row {
+ display: flex;
+ gap: var(--space-3, 0.75rem);
+ max-width: 600px;
+}
+
+.git-vc-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ border: 1px solid var(--border-default);
+ border-radius: var(--radius-md, 8px);
+ background: var(--surface-bg);
+ overflow: hidden;
+}
+
+.git-vc-list-item {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3, 0.75rem);
+ padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
+ border-bottom: 1px solid var(--border-default);
+}
+
+.git-vc-list-item:last-child {
+ border-bottom: none;
+}
+
+.git-vc-list-item--current {
+ background: var(--surface-accent);
+}
+
+.git-vc-list-item__name {
+ font-family: monospace;
+ font-size: var(--font-size-body, 14px);
+ flex: 1;
+}
+
+.git-vc-list-item__badge {
+ font-size: 10px;
+ background: var(--accent-solid);
+ color: var(--text-on-accent);
+ padding: 2px 6px;
+ border-radius: 10px;
+ font-weight: bold;
+}
+
+.git-vc-list-item__meta {
+ font-family: monospace;
+ font-size: var(--font-size-caption, 11px);
+ color: var(--text-muted);
+}
+
+.git-vc-list-item__actions {
+ display: flex;
+ gap: var(--space-2, 0.5rem);
+}
+
+/* Remotes Tab */
+.git-vc-remotes {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-8, 2rem);
+}
+
+.git-vc-remotes__sync {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4, 1rem);
+ padding: var(--space-4, 1rem);
+ border: 1px solid var(--border-default);
+ border-radius: var(--radius-md, 8px);
+ background: var(--surface-bg);
+}
+
+.git-vc-remotes__sync-status {
+ font-size: var(--font-size-body, 14px);
+ color: var(--app-text);
+}
+
+.git-vc-remotes__pat-row {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2, 0.5rem);
+ max-width: 500px;
+}
+
+.git-vc-remotes__pat-label {
+ font-size: var(--font-size-label, 12px);
+ color: var(--text-muted);
+ font-weight: 500;
+}
+
+.git-vc-remotes__pat-hint {
+ font-size: 11px;
+ color: var(--text-muted);
+}
+
+.git-vc-remotes__sync-actions {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3, 0.75rem);
+ margin-top: var(--space-2, 0.5rem);
+}
+
+.git-vc-remotes__sync-group {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3, 0.75rem);
+}
+
+.git-vc-remotes__sync-remote {
+ font-weight: bold;
+ font-size: var(--font-size-body, 14px);
+ min-width: 100px;
+}
+
+.git-vc-remotes__add-fields {
+ display: flex;
+ gap: var(--space-3, 0.75rem);
+ max-width: 800px;
+}
+
+/* Repository Tab */
+.git-vc-repository {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-6, 1.5rem);
+}
+
+.git-vc-repository__stats {
+ border: 1px solid var(--border-default);
+ border-radius: var(--radius-md, 8px);
+ background: var(--surface-bg);
+ padding: var(--space-4, 1rem);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4, 1rem);
+}
+
+.git-vc-repository__stat {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1, 0.25rem);
+}
+
+.git-vc-repository__stat dt {
+ font-size: var(--font-size-label, 12px);
+ color: var(--text-muted);
+ font-weight: 500;
+}
+
+.git-vc-repository__stat dd {
+ font-size: var(--font-size-body, 14px);
+ color: var(--app-text);
+ margin: 0;
+}
+
+.git-vc-repository__path {
+ font-family: monospace;
+ word-break: break-all;
+}
+
+.git-vc-repository__contributors {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1, 0.25rem);
+}
+
+.git-vc-repository__actions {
+ display: flex;
+}
+
+/* Settings Tab */
+.git-vc-settings {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4, 1rem);
+ max-width: 600px;
+}
+
+.git-vc-settings__desc {
+ font-size: var(--font-size-body, 14px);
+ color: var(--text-muted);
+ line-height: 1.5;
+}
+
+.git-vc-settings__code {
+ background: var(--surface-muted);
+ border: 1px solid var(--border-default);
+ border-radius: var(--radius-md, 8px);
+ padding: var(--space-4, 1rem);
+ font-family: monospace;
+ font-size: var(--font-size-body-sm, 13px);
+ color: var(--app-text);
+ line-height: 1.6;
+ white-space: pre-wrap;
+}
+
+/* Status Bar Button (Bottom Toolbar) */
+.git-status-bar {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2, 0.5rem);
+ cursor: pointer;
+ border: none;
+ font-weight: 500;
+ transition: background var(--motion-fast, 120ms ease);
+}
+
+.git-status-bar:hover {
+ background: var(--surface-accent);
+}
+
+.git-status-bar--loading {
+ opacity: 0.7;
+}
+
+.git-status-bar--warn {
+ color: var(--status-warning-text);
+}
+
+.git-status-bar--pending {
+ background: var(--status-warning-bg) !important;
+ color: var(--status-warning-text) !important;
+ padding: var(--space-1, 0.25rem) var(--space-2, 0.5rem);
+ border-radius: var(--radius-sm, 4px);
+ border: 1px solid var(--status-warning-border) !important;
+}
+
+.git-status-bar--clean {
+ background: var(--status-success-bg) !important;
+ color: var(--status-success-text) !important;
+ padding: var(--space-1, 0.25rem) var(--space-2, 0.5rem);
+ border-radius: var(--radius-sm, 4px);
+ border: 1px solid var(--status-success-border) !important;
+}
+
+.git-status-bar__branch {
+ max-width: 120px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.git-status-bar__badge {
+ font-size: 10px;
+ background: var(--status-warning-border);
+ color: var(--status-warning-text);
+ padding: 1px 5px;
+ border-radius: 8px;
+ font-weight: bold;
+}
+
+/* Commit Timeline */
+.git-commit-timeline {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4, 1rem);
+ width: 100%;
+}
+
+.git-timeline-search {
+ display: flex;
+ margin-bottom: var(--space-2, 0.5rem);
+}
+
+.git-timeline-search__input {
+ width: 100%;
+ padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
+ background: var(--surface-muted);
+ border: 1px solid var(--border-default);
+ border-radius: var(--radius-md, 8px);
+ color: var(--app-text);
+ font-size: var(--font-size-body, 14px);
+}
+
+.git-timeline-list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3, 0.75rem);
+ width: 100%;
+}
+
+.git-commit-item {
+ border: 1px solid var(--border-default);
+ border-radius: var(--radius-md, 8px);
+ background: var(--surface-bg);
+ padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2, 0.5rem);
+ transition: all var(--motion-fast, 120ms ease);
+}
+
+.git-commit-item:hover {
+ border-color: var(--border-soft);
+ box-shadow: var(--shadow-sm);
+}
+
+.git-commit-item--selected {
+ border-color: var(--accent-solid);
+ box-shadow: var(--shadow-md);
+}
+
+.git-commit-item--compare-a {
+ border-color: var(--status-warning-border);
+ background: var(--status-warning-bg);
+}
+
+.git-commit-item__row {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3, 0.75rem);
+ width: 100%;
+ flex-wrap: nowrap;
+}
+
+.git-commit-item__icon {
+ color: var(--text-muted);
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+}
+
+.git-commit-item__hash {
+ font-family: monospace;
+ font-weight: bold;
+ font-size: var(--font-size-body-sm, 13px);
+ color: var(--text-strong);
+ background: var(--surface-muted);
+ padding: 1px 6px;
+ border-radius: 4px;
+ flex-shrink: 0;
+}
+
+.git-commit-item__date {
+ font-size: var(--font-size-caption, 11px);
+ color: var(--text-muted);
+ flex-shrink: 0;
+}
+
+.git-commit-item__separator {
+ color: var(--text-muted);
+ opacity: 0.5;
+ flex-shrink: 0;
+ user-select: none;
+}
+
+.git-commit-item__message {
+ font-size: var(--font-size-body, 14px);
+ font-weight: 500;
+ color: var(--app-text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ flex: 1;
+ min-width: 120px;
+}
+
+.git-commit-item__author {
+ font-size: var(--font-size-caption, 11px);
+ color: var(--text-muted);
+ flex-shrink: 0;
+}
+
+.git-commit-item__ref {
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ font-size: 10px;
+ font-weight: 600;
+ padding: 1px 6px;
+ border-radius: var(--radius-pill, 999px);
+ flex-shrink: 0;
+}
+
+.git-commit-item__ref--branch {
+ background: var(--status-info-bg);
+ color: var(--status-info-text);
+ border: 1px solid var(--status-info-border);
+}
+
+.git-commit-item__ref--tag {
+ background: var(--status-success-bg);
+ color: var(--status-success-text);
+ border: 1px solid var(--status-success-border);
+}
+
+.git-commit-item__actions {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2, 0.5rem);
+ margin-left: auto;
+ flex-shrink: 0;
+}
+
+.git-commit-item__files-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ background: transparent;
+ border: none;
+ color: var(--accent-solid);
+ font-size: var(--font-size-label, 12px);
+ font-weight: 600;
+ flex-shrink: 0;
+ cursor: pointer;
+ padding: 0;
+ align-self: flex-start;
+}
+
+.git-commit-item__files-list {
+ list-style: none;
+ padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
+ margin: var(--space-1, 0.25rem) 0;
+ background: var(--surface-muted);
+ border-radius: var(--radius-md, 8px);
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.git-commit-file {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3, 0.75rem);
+ font-family: monospace;
+ font-size: var(--font-size-body-sm, 13px);
+}
+
+.git-commit-file__status {
+ font-weight: bold;
+ width: 16px;
+}
+
+.git-commit-file--m .git-commit-file__status { color: var(--status-warning-text); }
+.git-commit-file--a .git-commit-file__status { color: var(--status-success-text); }
+.git-commit-file--d .git-commit-file__status { color: var(--status-danger-text); }
+
+.git-commit-file__path {
+ color: var(--app-text);
+ word-break: break-all;
+}
+
+.git-timeline-empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: var(--space-8, 2rem);
+ text-align: center;
+ color: var(--text-muted);
+ gap: var(--space-3, 0.75rem);
+}
+
+.git-timeline-empty__icon {
+ color: var(--text-muted);
+}
+
+.git-timeline-empty__text {
+ font-size: var(--font-size-body, 14px);
+}
+
+.git-timeline-spinner {
+ display: inline-block;
+ width: 24px;
+ height: 24px;
+ border: 2px solid var(--border-soft);
+ border-top-color: var(--accent-solid);
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+/* Commit Dialog */
+.git-commit-dialog {
+ width: min(540px, calc(100vw - 24px));
+ max-height: calc(100vh - 40px);
+ display: flex;
+ flex-direction: column;
+ background: var(--surface-bg);
+ border: 1px solid var(--border-default);
+ border-radius: var(--radius-xl, 12px);
+ box-shadow: var(--shadow-xl);
+ overflow: hidden;
+}
+
+.git-commit-dialog__header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: var(--space-4, 1rem) var(--space-5, 1.25rem);
+ border-bottom: 1px solid var(--border-default);
+}
+
+.git-commit-dialog__title-row {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2, 0.5rem);
+ color: var(--text-strong);
+}
+
+.git-commit-dialog__title {
+ font-size: var(--font-size-heading-md, 18px);
+ font-weight: 600;
+ margin: 0;
+}
+
+.git-commit-dialog__body {
+ padding: var(--space-5, 1.25rem);
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-5, 1.25rem);
+ flex: 1;
+}
+
+.git-commit-dialog__empty {
+ font-size: var(--font-size-body, 14px);
+ color: var(--text-muted);
+ text-align: center;
+ margin: var(--space-4, 1rem) 0;
+}
+
+.git-commit-dialog__files {
+ border: 1px solid var(--border-default);
+ border-radius: var(--radius-md, 8px);
+ padding: var(--space-3, 0.75rem);
+ margin: 0;
+ max-height: 200px;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2, 0.5rem);
+}
+
+.git-commit-dialog__files-label {
+ font-size: var(--font-size-label, 12px);
+ font-weight: 600;
+ color: var(--text-muted);
+ padding: 0 4px;
+}
+
+.git-commit-dialog__file-row {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3, 0.75rem);
+ font-size: var(--font-size-body-sm, 13px);
+ cursor: pointer;
+}
+
+.git-commit-dialog__file-checkbox {
+ cursor: pointer;
+}
+
+.git-commit-dialog__file-status {
+ font-family: monospace;
+ font-weight: bold;
+ font-size: 11px;
+ width: 14px;
+ text-align: center;
+}
+
+.git-file-status--m { color: var(--status-warning-text); }
+.git-file-status--a { color: var(--status-success-text); }
+.git-file-status--d { color: var(--status-danger-text); }
+.git-file-status--u { color: var(--text-muted); }
+
+.git-commit-dialog__file-path {
+ font-family: monospace;
+ color: var(--app-text);
+ word-break: break-all;
+ flex: 1;
+}
+
+.git-commit-dialog__message-area {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2, 0.5rem);
+}
+
+.git-commit-dialog__message-label {
+ font-size: var(--font-size-label, 12px);
+ font-weight: 600;
+ color: var(--text-muted);
+ display: flex;
+ justify-content: space-between;
+}
+
+.git-commit-dialog__char-count {
+ font-size: 11px;
+ font-weight: normal;
+}
+
+.git-commit-dialog__char-count.warn { color: var(--status-warning-text); }
+.git-commit-dialog__char-count.error { color: var(--status-danger-text); }
+
+.git-commit-dialog__message-input {
+ width: 100%;
+ background: var(--surface-muted);
+ border: 1px solid var(--border-default);
+ border-radius: var(--radius-md, 8px);
+ color: var(--app-text);
+ padding: var(--space-3, 0.75rem);
+ font-family: inherit;
+ font-size: var(--font-size-body, 14px);
+ resize: vertical;
+}
+
+.git-commit-dialog__message-input:focus {
+ outline: none;
+ border-color: var(--accent-solid);
+}
+
+.git-commit-dialog__message-input.error {
+ border-color: var(--status-danger-border);
+}
+
+.git-commit-dialog__hint {
+ font-size: var(--font-size-caption, 11px);
+ color: var(--text-muted);
+ text-align: right;
+}
+
+.git-commit-dialog__error {
+ font-size: var(--font-size-body-sm, 13px);
+ color: var(--status-danger-text);
+ background: var(--status-danger-bg);
+ border: 1px solid var(--status-danger-border);
+ padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
+ border-radius: var(--radius-sm, 6px);
+ margin: 0;
+}
+
+.git-commit-dialog__footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: var(--space-3, 0.75rem);
+ padding: var(--space-4, 1rem) var(--space-5, 1.25rem);
+ border-top: 1px solid var(--border-default);
+ background: var(--surface-elevated);
+}
+
+/* Git Diff Viewer */
+.git-diff-viewer {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4, 1rem);
+ width: 100%;
+}
+
+.git-diff-header {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3, 0.75rem);
+}
+
+.git-diff-labels {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2, 0.5rem);
+}
+
+.git-diff-label {
+ font-family: monospace;
+ font-size: var(--font-size-body-sm, 13px);
+ background: var(--surface-accent);
+ color: var(--text-strong);
+ padding: 2px 8px;
+ border-radius: 4px;
+ border: 1px solid var(--border-default);
+}
+
+.git-diff-label__arrow {
+ color: var(--text-muted);
+}
+
+.git-diff-viewer__header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--space-3, 0.75rem);
+ padding-bottom: var(--space-3, 0.75rem);
+ border-bottom: 1px solid var(--border-default);
+}
+
+.git-diff-viewer__title {
+ font-size: var(--font-size-heading-md, 18px);
+ font-weight: 600;
+ color: var(--text-strong);
+ margin: 0;
+}
+
+.git-diff-viewer__toolbar {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3, 0.75rem);
+}
+
+.git-diff-viewer__toggle-label {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: var(--font-size-body-sm, 13px);
+ cursor: pointer;
+ color: var(--text-muted);
+}
+
+.git-diff-viewer__toggle-checkbox {
+ cursor: pointer;
+}
+
+.git-diff-section {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2, 0.5rem);
+}
+
+.git-diff-section__title {
+ font-size: var(--font-size-body, 14px);
+ font-weight: 600;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin: 0;
+}
+
+.git-diff-lines {
+ font-family: inherit;
+ font-size: var(--font-size-body, 14px);
+ border: 1px solid var(--border-default);
+ border-radius: var(--radius-md, 8px);
+ background: var(--surface-bg);
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+.git-diff-lines--code {
+ font-family: var(--font-mono, monospace);
+ font-size: var(--font-size-body-sm, 13px);
+}
+
+.git-diff-no-changes {
+ padding: var(--space-4, 1rem);
+ color: var(--text-muted);
+ font-style: italic;
+ text-align: center;
+ background: var(--surface-muted);
+}
+
+.git-diff-row {
+ display: flex;
+ align-items: stretch;
+ line-height: 1.6;
+}
+
+.git-diff-lines--code .git-diff-row {
+ border-bottom: 1px solid var(--border-subtle);
+}
+
+.git-diff-lines--code .git-diff-row:last-child {
+ border-bottom: none;
+}
+
+.git-diff-row__gutter {
+ width: 45px;
+ min-width: 45px;
+ color: var(--text-muted);
+ text-align: right;
+ padding: 0 var(--space-3, 0.75rem);
+ user-select: none;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ opacity: 0.5;
+}
+
+.git-diff-lines--code .git-diff-row__gutter {
+ background: var(--surface-muted);
+ border-right: 1px solid var(--border-default);
+ opacity: 1;
+}
+
+.git-diff-row__content {
+ padding: var(--space-1, 0.25rem) var(--space-4, 1rem);
+ white-space: pre-wrap;
+ word-break: break-all;
+ flex: 1;
+ display: flex;
+ align-items: center;
+ min-height: 28px;
+}
+
+.git-diff-row__content.markdown-body p {
+ margin: 0 !important;
+ padding: 0 !important;
+ display: inline;
+}
+
+.git-diff-row__content.markdown-body {
+ font-family: inherit;
+ font-size: var(--font-size-body, 14px);
+ line-height: 1.6;
+}
+
+/* Row states */
+.git-diff-row--added {
+ background: var(--status-success-bg);
+ color: var(--status-success-text);
+}
+
+.git-diff-row--added .git-diff-row__gutter {
+ background: var(--status-success-border);
+ color: var(--status-success-text);
+ opacity: 0.8;
+}
+
+.git-diff-row--removed {
+ background: var(--status-danger-bg);
+ color: var(--status-danger-text);
+}
+
+.git-diff-row--removed .git-diff-row__gutter {
+ background: var(--status-danger-border);
+ color: var(--status-danger-text);
+ opacity: 0.8;
+}
+
+.git-diff-row--changed {
+ background: var(--status-warning-bg);
+}
+
+.git-diff-row--changed .git-diff-row__gutter {
+ background: var(--status-warning-border);
+ opacity: 0.8;
+}
+
+/* Word token highlights */
+.git-diff-token--added {
+ background: #c2f0c2;
+ color: #1b6654;
+ font-weight: 600;
+ padding: 1px 3px;
+ border-radius: 2px;
+ text-decoration: none;
+}
+
+.git-diff-token--removed {
+ background: #ffcccc;
+ color: #8e1b1b;
+ font-weight: 600;
+ padding: 1px 3px;
+ border-radius: 2px;
+ text-decoration: line-through;
+}
+
+[data-theme='dark'] .git-diff-token--added {
+ background: #173329;
+ color: #9ed8bf;
+}
+
+[data-theme='dark'] .git-diff-token--removed {
+ background: #3a1f20;
+ color: #ebb0b5;
+}
+
+.git-vc-page .p2p-tab-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2, 0.5rem);
+}
+
+.git-diff-viewer .markdown-image-actions,
+.git-diff-viewer .markdown-image-name {
+ display: none !important;
+}
+
+.git-diff-viewer img {
+ max-width: 100%;
+ max-height: 250px;
+ object-fit: contain;
+ border-radius: var(--radius-md, 8px);
+}
+
+.git-diff-row--empty {
+ line-height: 1;
+}
+
+.git-diff-row--empty .git-diff-row__content {
+ min-height: 12px;
+ height: 12px;
+ padding: 0 var(--space-4, 1rem) !important;
+}
+
+.git-diff-row--empty .git-diff-row__gutter {
+ color: transparent !important;
+ user-select: none;
+}
+
+/* Git Note History Panel Overlay Dialog */
+.git-history-panel {
+ width: min(900px, calc(100vw - 32px)) !important;
+ max-height: calc(100vh - 40px);
+ display: flex;
+ flex-direction: column;
+ background: var(--surface-bg);
+ border: 1px solid var(--border-default);
+ border-radius: var(--radius-xl, 12px);
+ box-shadow: var(--shadow-xl);
+ overflow: hidden;
+ position: relative;
+}
+
+.git-history-panel__header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: var(--space-4, 1rem) var(--space-5, 1.25rem);
+ border-bottom: 1px solid var(--border-default);
+ position: relative;
+}
+
+.git-history-panel__title-row {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2, 0.5rem);
+ color: var(--text-strong);
+}
+
+.git-history-panel__title {
+ font-size: var(--font-size-heading-md, 18px);
+ font-weight: 600;
+ margin: 0;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.git-history-panel__filename {
+ font-size: var(--font-size-body-sm, 13px);
+ color: var(--text-muted);
+ font-weight: normal;
+ background: var(--surface-muted);
+ padding: 2px 6px;
+ border-radius: 4px;
+}
+
+.git-history-panel__header-meta {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3, 0.75rem);
+ margin-left: auto;
+ margin-right: var(--space-6, 1.5rem);
+}
+
+.git-history-panel__branch {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 11px;
+ font-weight: 600;
+ background: var(--status-info-bg);
+ color: var(--status-info-text);
+ border: 1px solid var(--status-info-border);
+ padding: 1px 6px;
+ border-radius: var(--radius-pill, 999px);
+}
+
+.git-history-panel__count {
+ font-size: 11px;
+ color: var(--text-muted);
+ font-weight: 500;
+}
+
+.git-history-panel__close {
+ background: transparent;
+ border: none;
+ color: var(--text-muted);
+ cursor: pointer;
+ padding: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--radius-sm, 4px);
+ transition: background var(--motion-fast, 120ms ease), color var(--motion-fast, 120ms ease);
+}
+
+.git-history-panel__close:hover {
+ background: var(--surface-accent);
+ color: var(--text-strong);
+}
+
+.git-history-panel__body {
+ padding: var(--space-5, 1.25rem);
+ overflow-y: auto;
+ flex: 1;
+}
+
+
+
+