From 168fc023b0d0fba42bcfcb56984f7e69cd143c88 Mon Sep 17 00:00:00 2001 From: Bikash Panda Date: Sun, 12 Jul 2026 01:01:59 +0530 Subject: [PATCH 1/5] Git integration working !! --- electron/lib/core/appMenu.cjs | 96 +- electron/lib/documents/documentIpc.cjs | 84 -- electron/lib/git/gitIpc.cjs | 285 ++++ electron/lib/git/gitService.cjs | 1229 +++++++++++++++ electron/main.cjs | 23 + electron/preload.cjs | 44 +- package-lock.json | 48 + package.json | 1 + src/App.jsx | 163 +- src/components/DocumentDetail.jsx | 71 +- src/components/GitCommitDialog.jsx | 197 +++ src/components/GitCommitTimeline.jsx | 316 ++++ src/components/GitDiffViewer.jsx | 278 ++++ .../GitIntegration.integration.test.jsx | 186 +++ src/components/GitNoteHistoryPanel.jsx | 271 ++++ src/components/GitStatusBar.jsx | 80 + src/components/GitVersionControlPage.jsx | 1324 +++++++++++++++++ src/hooks/useDocumentManager.js | 7 +- src/services/electronService.js | 245 ++- src/styles.css | 1159 +++++++++++++++ 20 files changed, 5902 insertions(+), 205 deletions(-) create mode 100644 electron/lib/git/gitIpc.cjs create mode 100644 electron/lib/git/gitService.cjs create mode 100644 src/components/GitCommitDialog.jsx create mode 100644 src/components/GitCommitTimeline.jsx create mode 100644 src/components/GitDiffViewer.jsx create mode 100644 src/components/GitIntegration.integration.test.jsx create mode 100644 src/components/GitNoteHistoryPanel.jsx create mode 100644 src/components/GitStatusBar.jsx create mode 100644 src/components/GitVersionControlPage.jsx diff --git a/electron/lib/core/appMenu.cjs b/electron/lib/core/appMenu.cjs index a8bc290..1bfc8fe 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,97 @@ 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: "Branches", + submenu: [ + { + label: "Create Branch", + click: () => sendMenuAction(win, "git-create-branch") + }, + { + label: "Switch Branch", + click: () => sendMenuAction(win, "git-switch-branch") + }, + { + label: "Merge Branch", + click: () => sendMenuAction(win, "git-merge-branch") + } + ] + }, + { + label: "Tags\u2026", + click: () => sendMenuAction(win, "git-tags") + }, + { + label: "Stash\u2026", + click: () => sendMenuAction(win, "git-stash") + }, + { 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") + }, + { type: "separator" }, + { + label: "Open Repository in Explorer", + click: () => sendMenuAction(win, "git-reveal-repo") + }, + { + label: "Reveal .git Folder", + click: () => sendMenuAction(win, "git-reveal-git-dir") + } + ] + }, { 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..1812c28 --- /dev/null +++ b/electron/lib/git/gitService.cjs @@ -0,0 +1,1229 @@ +/** + * 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); + 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..a5617ab 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,8 @@ 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 [globalCommitDialogOpen, setGlobalCommitDialogOpen] = useState(false); const [tasksPanelOpen, setTasksPanelOpen] = useState(false); const [allTasksPanelOpen, setAllTasksPanelOpen] = useState(false); const [recentNotesPanelOpen, setRecentNotesPanelOpen] = useState(false); @@ -335,6 +345,8 @@ export default function App() { branch: "", autoIgnoreMetadataInGit: true, gitignoreHasNotesApp: false, + pendingCount: 0, + files: [], }); const { @@ -664,17 +676,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 +724,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 +892,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 +1057,58 @@ export default function App() { return; } + if (action === "open-git-version-control") { + 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") { + setGitVCOpen(true); + // Switch tab to compare after mounting if possible (we default to status, compare can be chosen) + return; + } + + if (action === "toggle-auto-ignore-git-metadata") { + void handleToggleAutoIgnoreGitMetadata(); + return; + } + + if (action === "git-reveal-repo" || action === "git-reveal-git-dir") { + if (gitWorkspaceMeta.isGitRoot && gitWorkspaceMeta.workspaceRoot) { + const targetPath = action === "git-reveal-git-dir" + ? `${gitWorkspaceMeta.workspaceRoot}/.git` + : gitWorkspaceMeta.workspaceRoot; + revealWorkspaceInExplorer({ folderPath: targetPath }); + } else { + notify("Workspace is not a Git repository.", "warning"); + } + return; + } + + if (action === "open-p2p-sync-help") { setP2PSyncHelpOpen(true); return; @@ -1214,9 +1301,7 @@ export default function App() { } if (action === "manage-versions") { - if (current) { - setDocumentMenuAction({ action, nonce: Date.now() }); - } + // Deprecated version action return; } @@ -2105,24 +2190,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"} - - + onClick={() => setGitVCOpen(true)} + /> {current ? ( <> @@ -2296,6 +2373,8 @@ export default function App() { setHistory(await getHistory(current.filePath))} + onRefreshHistory={async () => setHistory([])} saving={saving} dirty={dirty} menuAction={documentMenuAction} @@ -2915,6 +2994,38 @@ export default function App() { )} + {gitVCOpen && ( +
+ Loading Version Control…
}> + setGitVCOpen(false)} + onNotify={notify} + onGitStateChange={handleGitStateChange} + currentFilePath={current?.filePath} + /> + + + )} + + {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"> - - -
-
- - - Refresh - -
- {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..0326ec1 --- /dev/null +++ b/src/components/GitCommitDialog.jsx @@ -0,0 +1,197 @@ +import { useState, useRef, useEffect } from "react"; +import { GitCommit, X } 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 ( + +
+
+
+ +
+ +
+ {stagedFiles.length === 0 ? ( +

+ No changed files to commit. Save your notes first. +

+ ) : ( + <> +
+ Files to commit + {stagedFiles.map((f) => ( + + ))} +
+ +
+ +