diff --git a/CHANGELOG.md b/CHANGELOG.md index d545153..fec045a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,50 @@ No changes yet. --- +## [1.1.0] - 2026-03-25 + +### Added + +- **Editor Bookmarks (1–9)** + - Set bookmarks using `Ctrl + Shift + 1–9` + - Jump to bookmarks using `Ctrl + 1–9` + - Bookmarks store file, line, and column position + - Per-repository persistence (switching repo switches bookmark set) + +- **Bookmark UI Integration** + - Gutter decorations with numbered icons (1–9) + - Subtle line highlight for bookmarked locations + - Hover tooltip indicating bookmark slot + +- **Bookmark Commands & Actions** + - Set bookmark from editor and changelist context menu + - Clear single bookmark + - Clear all bookmarks with confirmation + - Slot-specific commands for set / jump / clear + +- **Context Menu Support** + - Right-click -> Set Bookmark + - Right-click -> Clear Bookmark + - View title -> Clear All Bookmarks + +- **Overwrite Confirmation** + - Confirmation dialog when replacing an existing bookmark slot + +### Changed + +- Bookmark decorations now refresh automatically when: + - setting a bookmark + - clearing a bookmark + - switching editors + +### Fixed + +- Bookmark positioning now correctly respects cursor location when set via context menu +- Bookmark decorations now refresh immediately without requiring window reload +- Fixed stale changelist file decorations after bulk operations by ensuring stage-state refresh stays synchronized with the decoration provider. + +--- + ## [1.0.2] - 2026-03-22 ### Changed diff --git a/README.md b/README.md index b3b0594..a2bff1a 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ It provides explicit control over: - commits and amend - push workflows - stash management +- **editor navigation via bookmarks** All through a predictable UI built directly on top of the Git CLI. @@ -91,6 +92,14 @@ Inspect and manage stashed changes with file-level previews. --- +### Bookmarks and Navigation + +Set bookmarks and jump quickly between important locations in your code. + +![Bookmark demo](media/demo_bmrk.gif) + +--- + ## Key Features - IntelliJ-style changelists @@ -100,6 +109,7 @@ Inspect and manage stashed changes with file-level previews. - Built-in stash management - Drag and drop organization - Explicit Git workflow with no hidden behavior +- Editor bookmarks (1–9) with instant navigation --- @@ -130,6 +140,13 @@ Inspect and manage stashed changes with file-level previews. - Inspect stash contents - Apply, pop, and delete stashes +### Bookmarks + +- Set bookmarks (1–9) from editor or context menu +- Jump instantly between locations +- Gutter decorations with slot indicators +- Per-repository persistence + --- ## Documentation diff --git a/docs/MANUAL.md b/docs/MANUAL.md index 3c6dbe9..2e93d2d 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -137,7 +137,7 @@ Stage individual lines directly from the editor or diff view. 1. Open a file diff or source file 2. Select the lines you want to stage -3. Right-click → Stage Selected Lines +3. Right-click -> Stage Selected Lines Only the selected changes are staged. @@ -282,6 +282,115 @@ All operations use repo-relative paths. --- +## Bookmarks + +Quick navigation across files using numbered bookmarks (1–9), similar to IntelliJ. + +### Core Behavior + +* Supports **9 bookmark slots (1–9)** + +* Each slot holds **one location** + +* A bookmark stores: + + * file (repo-relative) + * line + * column + +* Bookmarks are **persisted per repository** + +* Switching repository switches bookmark set automatically + +--- + +### Setting Bookmarks + +You can set bookmarks in multiple ways: + +* **Keyboard** + + * `Ctrl + Shift + 1–9` or `Cmd + Shift + 1–9` -> Set bookmark +* **Editor right-click** + + * `GW: Set Bookmark…` + * `GW: Clear Bookmark…` + * `GW: Clear All Bookmark` +If a slot is already used: + +* A confirmation dialog is shown +* You can **replace or cancel** + +--- + +### Jumping to Bookmarks + +* `Ctrl + 1–9` or `Cmd + 1–9` -> Jump to bookmark + +Behavior: + +* Opens the file if not open +* Moves cursor to saved position +* Reveals location in editor + +--- + +### Clearing Bookmarks + +* Clear single bookmark: + + * Command: `Clear Bookmark` + * Slot-specific commands available + +* Clear all bookmarks: + + * View title action + * Context menu + * Confirmation required + +--- + +### Visual Indicators + +* Bookmarks are shown in the **editor gutter** + +* Each slot has its own icon (1–9) + +* Decorations include: + + * gutter icon + * subtle line highlight + +* Decorations update automatically when: + + * bookmark is set + * bookmark is cleared + * editor becomes visible + +--- + +### Behavior Notes + +* Works on **any file inside the repository** + + * not limited to changed files +* Bookmarks outside repo are ignored +* Line numbers are safely clamped if file changes +* Decorations are editor-only (not shown in tree view) + +--- + +### Keybindings + +Default: + +* `Ctrl + Shift + 1–9` or `Cmd + Shift + 1–9` -> Set bookmark +* `Ctrl + 1–9` or `Ctrl + 1–9` -> Jump to bookmark + +Users can override keybindings via VS Code settings. + +--- + ## Usage ### Changelists & Commits @@ -298,7 +407,7 @@ All operations use repo-relative paths. ### Stashes -1. Right-click a changelist → Stash changes +1. Right-click a changelist -> Stash changes 2. Enter optional message 3. Open Stashes view 4. Expand stash diff --git a/media/bookmarks/bookmark-1.svg b/media/bookmarks/bookmark-1.svg new file mode 100644 index 0000000..9232a0e --- /dev/null +++ b/media/bookmarks/bookmark-1.svg @@ -0,0 +1,4 @@ + + + 1 + \ No newline at end of file diff --git a/media/bookmarks/bookmark-2.svg b/media/bookmarks/bookmark-2.svg new file mode 100644 index 0000000..91aa1ea --- /dev/null +++ b/media/bookmarks/bookmark-2.svg @@ -0,0 +1,4 @@ + + + 2 + \ No newline at end of file diff --git a/media/bookmarks/bookmark-3.svg b/media/bookmarks/bookmark-3.svg new file mode 100644 index 0000000..29de669 --- /dev/null +++ b/media/bookmarks/bookmark-3.svg @@ -0,0 +1,4 @@ + + + 3 + \ No newline at end of file diff --git a/media/bookmarks/bookmark-4.svg b/media/bookmarks/bookmark-4.svg new file mode 100644 index 0000000..eec99d8 --- /dev/null +++ b/media/bookmarks/bookmark-4.svg @@ -0,0 +1,4 @@ + + + 4 + \ No newline at end of file diff --git a/media/bookmarks/bookmark-5.svg b/media/bookmarks/bookmark-5.svg new file mode 100644 index 0000000..62630d1 --- /dev/null +++ b/media/bookmarks/bookmark-5.svg @@ -0,0 +1,4 @@ + + + 5 + \ No newline at end of file diff --git a/media/bookmarks/bookmark-6.svg b/media/bookmarks/bookmark-6.svg new file mode 100644 index 0000000..67126b1 --- /dev/null +++ b/media/bookmarks/bookmark-6.svg @@ -0,0 +1,4 @@ + + + 6 + \ No newline at end of file diff --git a/media/bookmarks/bookmark-7.svg b/media/bookmarks/bookmark-7.svg new file mode 100644 index 0000000..6109b20 --- /dev/null +++ b/media/bookmarks/bookmark-7.svg @@ -0,0 +1,4 @@ + + + 7 + \ No newline at end of file diff --git a/media/bookmarks/bookmark-8.svg b/media/bookmarks/bookmark-8.svg new file mode 100644 index 0000000..92650aa --- /dev/null +++ b/media/bookmarks/bookmark-8.svg @@ -0,0 +1,4 @@ + + + 8 + \ No newline at end of file diff --git a/media/bookmarks/bookmark-9.svg b/media/bookmarks/bookmark-9.svg new file mode 100644 index 0000000..7d3f00d --- /dev/null +++ b/media/bookmarks/bookmark-9.svg @@ -0,0 +1,4 @@ + + + 9 + \ No newline at end of file diff --git a/media/demo_bmrk.gif b/media/demo_bmrk.gif new file mode 100644 index 0000000..0e7fc4e Binary files /dev/null and b/media/demo_bmrk.gif differ diff --git a/package.json b/package.json index 306207c..18b370c 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,15 @@ "diff preview", "conventional commits", "commit message", - "semantic commits" + "semantic commits", + "bookmark", + "bookmarks", + "editor bookmarks", + "code navigation", + "jump to bookmark", + "quick navigation", + "intellij bookmark", + "intellij bookmarks" ], "activationEvents": [], "main": "./out/extension.js", @@ -225,6 +233,129 @@ "command": "gitWorklists.stageSelectedLines", "title": "GW: Stage Selected Lines", "category": "Git Worklists" + }, + { + "command": "gitWorklists.bookmark.set", + "title": "GW:Set Bookmark…", + "icon": "$(bookmark)" + }, + { + "command": "gitWorklists.bookmark.clear", + "title": "GW: Clear Bookmark...", + "icon": "$(bookmark-slash)" + }, + { + "command": "gitWorklists.bookmark.clearAll", + "title": "GW: Clear All Bookmarks", + "icon": "$(clear-all)" + }, + { + "command": "gitWorklists.bookmark.set1", + "title": "Set Bookmark 1" + }, + { + "command": "gitWorklists.bookmark.set2", + "title": "Set Bookmark 2" + }, + { + "command": "gitWorklists.bookmark.set3", + "title": "Set Bookmark 3" + }, + { + "command": "gitWorklists.bookmark.set4", + "title": "Set Bookmark 4" + }, + { + "command": "gitWorklists.bookmark.set5", + "title": "Set Bookmark 5" + }, + { + "command": "gitWorklists.bookmark.set6", + "title": "Set Bookmark 6" + }, + { + "command": "gitWorklists.bookmark.set7", + "title": "Set Bookmark 7" + }, + { + "command": "gitWorklists.bookmark.set8", + "title": "Set Bookmark 8" + }, + { + "command": "gitWorklists.bookmark.set9", + "title": "Set Bookmark 9" + }, + { + "command": "gitWorklists.bookmark.jump1", + "title": "Jump to Bookmark 1" + }, + { + "command": "gitWorklists.bookmark.jump2", + "title": "Jump to Bookmark 2" + }, + { + "command": "gitWorklists.bookmark.jump3", + "title": "Jump to Bookmark 3" + }, + { + "command": "gitWorklists.bookmark.jump4", + "title": "Jump to Bookmark 4" + }, + { + "command": "gitWorklists.bookmark.jump5", + "title": "Jump to Bookmark 5" + }, + { + "command": "gitWorklists.bookmark.jump6", + "title": "Jump to Bookmark 6" + }, + { + "command": "gitWorklists.bookmark.jump7", + "title": "Jump to Bookmark 7" + }, + { + "command": "gitWorklists.bookmark.jump8", + "title": "Jump to Bookmark 8" + }, + { + "command": "gitWorklists.bookmark.jump9", + "title": "Jump to Bookmark 9" + }, + { + "command": "gitWorklists.bookmark.clear1", + "title": "Clear Bookmark 1" + }, + { + "command": "gitWorklists.bookmark.clear2", + "title": "Clear Bookmark 2" + }, + { + "command": "gitWorklists.bookmark.clear3", + "title": "Clear Bookmark 3" + }, + { + "command": "gitWorklists.bookmark.clear4", + "title": "Clear Bookmark 4" + }, + { + "command": "gitWorklists.bookmark.clear5", + "title": "Clear Bookmark 5" + }, + { + "command": "gitWorklists.bookmark.clear6", + "title": "Clear Bookmark 6" + }, + { + "command": "gitWorklists.bookmark.clear7", + "title": "Clear Bookmark 7" + }, + { + "command": "gitWorklists.bookmark.clear8", + "title": "Clear Bookmark 8" + }, + { + "command": "gitWorklists.bookmark.clear9", + "title": "Clear Bookmark 9" } ], "menus": { @@ -248,6 +379,11 @@ "command": "gitWorklists.stash.refresh", "when": "view == gitWorklists.stashes", "group": "navigation@1" + }, + { + "command": "gitWorklists.bookmark.clearAll", + "when": "view == gitWorklists.changelists", + "group": "navigation@3" } ], "view/item/context": [ @@ -352,9 +488,134 @@ "command": "gitWorklists.stageSelectedLines", "when": "editorHasSelection && !editorReadonly", "group": "gitWorklists@1" + }, + { + "command": "gitWorklists.bookmark.set", + "when": "!editorReadonly", + "group": "gitWorklists@2" + }, + { + "command": "gitWorklists.bookmark.clear", + "when": "!editorReadonly", + "group": "gitWorklists@3" + }, + { + "command": "gitWorklists.bookmark.clearAll", + "when": "!editorReadonly", + "group": "gitWorklists@4" } ] - } + }, + "keybindings": [ + { + "command": "gitWorklists.bookmark.set1", + "key": "ctrl+shift+1", + "mac": "cmd+shift+1", + "when": "editorTextFocus && !editorReadonly" + }, + { + "command": "gitWorklists.bookmark.jump1", + "key": "ctrl+1", + "mac": "cmd+1", + "when": "editorTextFocus" + }, + { + "command": "gitWorklists.bookmark.set2", + "key": "ctrl+shift+2", + "mac": "cmd+shift+2", + "when": "editorTextFocus && !editorReadonly" + }, + { + "command": "gitWorklists.bookmark.jump2", + "key": "ctrl+2", + "mac": "cmd+2", + "when": "editorTextFocus" + }, + { + "command": "gitWorklists.bookmark.set3", + "key": "ctrl+shift+3", + "mac": "cmd+shift+3", + "when": "editorTextFocus && !editorReadonly" + }, + { + "command": "gitWorklists.bookmark.jump3", + "key": "ctrl+3", + "mac": "cmd+3", + "when": "editorTextFocus" + }, + { + "command": "gitWorklists.bookmark.set4", + "key": "ctrl+shift+4", + "mac": "cmd+shift+4", + "when": "editorTextFocus && !editorReadonly" + }, + { + "command": "gitWorklists.bookmark.jump4", + "key": "ctrl+4", + "mac": "cmd+4", + "when": "editorTextFocus" + }, + { + "command": "gitWorklists.bookmark.set5", + "key": "ctrl+shift+5", + "mac": "cmd+shift+5", + "when": "editorTextFocus && !editorReadonly" + }, + { + "command": "gitWorklists.bookmark.jump5", + "key": "ctrl+5", + "mac": "cmd+5", + "when": "editorTextFocus" + }, + { + "command": "gitWorklists.bookmark.set6", + "key": "ctrl+shift+6", + "mac": "cmd+shift+6", + "when": "editorTextFocus && !editorReadonly" + }, + { + "command": "gitWorklists.bookmark.jump6", + "key": "ctrl+6", + "mac": "cmd+6", + "when": "editorTextFocus" + }, + { + "command": "gitWorklists.bookmark.set7", + "key": "ctrl+shift+7", + "mac": "cmd+shift+7", + "when": "editorTextFocus && !editorReadonly" + }, + { + "command": "gitWorklists.bookmark.jump7", + "key": "ctrl+7", + "mac": "cmd+7", + "when": "editorTextFocus" + }, + { + "command": "gitWorklists.bookmark.set8", + "key": "ctrl+shift+8", + "mac": "cmd+shift+8", + "when": "editorTextFocus && !editorReadonly" + }, + { + "command": "gitWorklists.bookmark.jump8", + "key": "ctrl+8", + "mac": "cmd+8", + "when": "editorTextFocus" + }, + { + "command": "gitWorklists.bookmark.set9", + "key": "ctrl+shift+9", + "mac": "cmd+shift+9", + "when": "editorTextFocus && !editorReadonly" + }, + { + "command": "gitWorklists.bookmark.jump9", + "key": "ctrl+9", + "mac": "cmd+9", + "when": "editorTextFocus" + } + ] }, "scripts": { "vscode:prepublish": "npm run compile", diff --git a/src/adapters/storage/workspaceStateStore.ts b/src/adapters/storage/workspaceStateStore.ts index 855afe6..405188f 100644 --- a/src/adapters/storage/workspaceStateStore.ts +++ b/src/adapters/storage/workspaceStateStore.ts @@ -1,4 +1,6 @@ import { normalizeRepoRelPath } from "../../utils/paths"; +import type { BookmarkEntry, BookmarkSlot } from "../../core/bookmark/bookmark"; +import type { BookmarkRepository } from "../../core/bookmark/bookmarkRepository"; import type { MementoLike } from "../vscode/mementoFacade"; export type PersistedChangelist = { @@ -17,7 +19,23 @@ type PersistedSelectionState = { selectedFiles: string[]; }; -export class WorkspaceStateStore { +type PersistedBookmarkTarget = { + repoRelativePath: string; + line: number; + column: number; +}; + +type PersistedBookmarkEntry = { + slot: BookmarkSlot; + target: PersistedBookmarkTarget; +}; + +type PersistedBookmarksState = { + version: 1; + entries: PersistedBookmarkEntry[]; +}; + +export class WorkspaceStateStore implements BookmarkRepository { constructor(private readonly memento: MementoLike) {} private keyForRepo(repoRootFsPath: string): string { @@ -28,6 +46,10 @@ export class WorkspaceStateStore { return `git-worklists.selection.v1:${repoRootFsPath}`; } + private bookmarksKeyForRepo(repoRootFsPath: string): string { + return `git-worklists.bookmarks.v1:${repoRootFsPath}`; + } + async load(repoRootFsPath: string): Promise { return this.memento.get(this.keyForRepo(repoRootFsPath)); } @@ -79,4 +101,101 @@ export class WorkspaceStateStore { async clearSelectedFiles(repoRootFsPath: string): Promise { await this.setSelectedFiles(repoRootFsPath, new Set()); } + + async getAll(repoRootFsPath: string): Promise { + const raw = this.memento.get( + this.bookmarksKeyForRepo(repoRootFsPath), + ); + + if (raw?.version !== 1) { + return []; + } + + return raw.entries + .map((entry) => ({ + slot: entry.slot, + target: { + repoRelativePath: normalizeRepoRelPath(entry.target.repoRelativePath), + line: entry.target.line, + column: entry.target.column, + }, + })) + .sort((a, b) => a.slot - b.slot); + } + + async getBySlot( + repoRootFsPath: string, + slot: BookmarkSlot, + ): Promise { + const all = await this.getAll(repoRootFsPath); + return all.find((entry) => entry.slot === slot); + } + + async set(repoRootFsPath: string, entry: BookmarkEntry): Promise { + const all = await this.getAll(repoRootFsPath); + + const normalizedEntry: BookmarkEntry = { + slot: entry.slot, + target: { + repoRelativePath: normalizeRepoRelPath(entry.target.repoRelativePath), + line: entry.target.line, + column: entry.target.column, + }, + }; + + const next = all.filter((item) => item.slot !== normalizedEntry.slot); + next.push(normalizedEntry); + next.sort((a, b) => a.slot - b.slot); + + const payload: PersistedBookmarksState = { + version: 1, + entries: next.map((item) => ({ + slot: item.slot, + target: { + repoRelativePath: normalizeRepoRelPath(item.target.repoRelativePath), + line: item.target.line, + column: item.target.column, + }, + })), + }; + + await this.memento.update( + this.bookmarksKeyForRepo(repoRootFsPath), + payload, + ); + } + + async clear(repoRootFsPath: string, slot: BookmarkSlot): Promise { + const all = await this.getAll(repoRootFsPath); + const next = all.filter((entry) => entry.slot !== slot); + + const payload: PersistedBookmarksState = { + version: 1, + entries: next.map((item) => ({ + slot: item.slot, + target: { + repoRelativePath: normalizeRepoRelPath(item.target.repoRelativePath), + line: item.target.line, + column: item.target.column, + }, + })), + }; + + await this.memento.update( + this.bookmarksKeyForRepo(repoRootFsPath), + payload, + ); + } + + async clearAll(repoRootFsPath: string): Promise { + const payload: PersistedBookmarksState = { + version: 1, + entries: [], + }; + + await this.memento.update( + this.bookmarksKeyForRepo(repoRootFsPath), + payload, + ); + } } diff --git a/src/adapters/vscode/bookmarkEditor.ts b/src/adapters/vscode/bookmarkEditor.ts new file mode 100644 index 0000000..ea01d5c --- /dev/null +++ b/src/adapters/vscode/bookmarkEditor.ts @@ -0,0 +1,69 @@ +import * as path from "path"; +import * as vscode from "vscode"; +import type { BookmarkTarget } from "../../core/bookmark/bookmark"; + +export class VsCodeBookmarkEditor { + getActiveEditorTarget(repoRoot: string): BookmarkTarget | undefined { + const editor = vscode.window.activeTextEditor; + if (!editor) { + return undefined; + } + + if (editor.document.isUntitled) { + return undefined; + } + + const filePath = editor.document.uri.fsPath; + const normalizedRepoRoot = path.resolve(repoRoot); + const normalizedFilePath = path.resolve(filePath); + + if (!this.isInsideRepo(normalizedRepoRoot, normalizedFilePath)) { + return undefined; + } + + const repoRelativePath = path.relative(normalizedRepoRoot, normalizedFilePath); + const pos = editor.selection.active; + + return { + repoRelativePath, + line: pos.line, + column: pos.character, + }; + } + + async openTarget(repoRoot: string, target: BookmarkTarget): Promise { + const absolutePath = path.join(repoRoot, target.repoRelativePath); + const uri = vscode.Uri.file(absolutePath); + const doc = await vscode.workspace.openTextDocument(uri); + const editor = await vscode.window.showTextDocument(doc, { preview: false }); + + const position = new vscode.Position(target.line, target.column); + editor.selection = new vscode.Selection(position, position); + editor.revealRange(new vscode.Range(position, position)); + } + + getTargetFromFsPath( + repoRoot: string, + absoluteFilePath: string, + line = 0, + column = 0, + ): BookmarkTarget | undefined { + const normalizedRepoRoot = path.resolve(repoRoot); + const normalizedFilePath = path.resolve(absoluteFilePath); + + if (!this.isInsideRepo(normalizedRepoRoot, normalizedFilePath)) { + return undefined; + } + + return { + repoRelativePath: path.relative(normalizedRepoRoot, normalizedFilePath), + line, + column, + }; + } + + private isInsideRepo(repoRoot: string, filePath: string): boolean { + const rel = path.relative(repoRoot, filePath); + return !rel.startsWith("..") && !path.isAbsolute(rel); + } +} \ No newline at end of file diff --git a/src/adapters/vscode/prompt.ts b/src/adapters/vscode/prompt.ts index 3de2548..56bcf01 100644 --- a/src/adapters/vscode/prompt.ts +++ b/src/adapters/vscode/prompt.ts @@ -1,4 +1,10 @@ import * as vscode from "vscode"; +import { + BOOKMARK_SLOTS, + formatBookmarkTarget, + type BookmarkEntry, + type BookmarkSlot, +} from "../../core/bookmark/bookmark"; export type NewFileDecision = "add" | "keep" | "disable" | "dismiss"; @@ -30,4 +36,62 @@ export class VsCodePrompt { } return "dismiss"; } -} + + async pickBookmarkSlot(): Promise { + const picked = await vscode.window.showQuickPick( + BOOKMARK_SLOTS.map((slot) => ({ + label: `Bookmark ${slot}`, + description: `Slot ${slot}`, + slot, + })), + { + placeHolder: "Select bookmark slot", + ignoreFocusOut: true, + }, + ); + + return picked?.slot; + } + + async confirmBookmarkOverwrite( + existing: BookmarkEntry, + incoming: BookmarkEntry, + ): Promise { + const answer = await vscode.window.showWarningMessage( + `Bookmark ${existing.slot} is already set.`, + { + modal: true, + detail: [ + `Current: ${formatBookmarkTarget(existing.target)}`, + `New: ${formatBookmarkTarget(incoming.target)}`, + "", + "Do you want to replace it?", + ].join("\n"), + }, + "Replace", + ); + + return answer === "Replace"; + } + + async confirmClearAllBookmarks(count: number): Promise { + const answer = await vscode.window.showWarningMessage( + `Clear all bookmarks?`, + { + modal: true, + detail: `This will remove ${count} bookmark(s) for the current repository.`, + }, + "Clear All", + ); + + return answer === "Clear All"; + } + + async showInfo(message: string): Promise { + void vscode.window.showInformationMessage(message); + } + + async showWarning(message: string): Promise { + void vscode.window.showWarningMessage(message); + } +} \ No newline at end of file diff --git a/src/app/deps.ts b/src/app/deps.ts index 76af3ab..b0c31cd 100644 --- a/src/app/deps.ts +++ b/src/app/deps.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode"; import { GitCliClient } from "../adapters/git/gitCliClient"; import { WorkspaceStateStore } from "../adapters/storage/workspaceStateStore"; +import { VsCodeBookmarkEditor } from "../adapters/vscode/bookmarkEditor"; import { conventionalCommitsAdapter } from "../adapters/vscode/conventionalCommitsAdapter"; import { DiffTabTracker } from "../adapters/vscode/diffTabTracker"; import { findWorkspaceRepoRoots } from "../adapters/vscode/findWorkspaceRepoRoots"; @@ -13,6 +14,10 @@ import { } from "../adapters/vscode/repoWatchers"; import { VsCodeSettings } from "../adapters/vscode/settings"; import { RefreshCoordinator } from "../core/refresh/refreshCoordinator"; +import { ClearAllBookmarks } from "../usecases/bookmark/clearAllBookmarks"; +import { ClearBookmark } from "../usecases/bookmark/clearBookmark"; +import { JumpToBookmark } from "../usecases/bookmark/jumpToBookmark"; +import { SetBookmark } from "../usecases/bookmark/setBookmark"; import { CloseDiffTabs } from "../usecases/closeDiffTabs"; import { CreateChangelist } from "../usecases/createChangelist"; import { DeleteChangelist } from "../usecases/deleteChangelist"; @@ -28,6 +33,7 @@ import { ChangelistTreeProvider } from "../views/changelistTreeProvider"; import { StashesTreeProvider } from "../views/stash/stashesTreeProvider"; import { WorklistDecorationProvider } from "../views/worklistDecorationProvider"; import { Deps } from "./types"; +import { BookmarkDecorationProvider } from "../views/bookmark/bookmarkDecorationProvider"; function sortRepoRoots(repoRoots: string[]): string[] { return [...new Set(repoRoots)].sort((a, b) => a.localeCompare(b)); @@ -66,6 +72,14 @@ export async function createDeps( const fsStat = new VsCodeFsStat(); const settings = new VsCodeSettings(); const prompt = new VsCodePrompt(); + const bookmarkEditor = new VsCodeBookmarkEditor(); + const bookmarkDeco = new BookmarkDecorationProvider(store, context); + bookmarkDeco.setRepoRoot(repoRoot); + + const setBookmark = new SetBookmark(store, prompt); + const jumpToBookmark = new JumpToBookmark(store, bookmarkEditor, prompt); + const clearBookmark = new ClearBookmark(store, prompt); + const clearAllBookmarks = new ClearAllBookmarks(store, prompt); const diffTabTracker = new DiffTabTracker(); const closeDiffTabs = new CloseDiffTabs(diffTabTracker); @@ -102,9 +116,20 @@ export async function createDeps( await loadOrInit.run(deps.repoRoot); await reconcile.run(deps.repoRoot); + const fileStageStates = await git.getFileStageStates(deps.repoRoot); + treeProvider.setFileStageStates(fileStageStates); + deco.setFileStageStates(fileStageStates); + treeProvider.refresh(); deco.refreshAll(); + if (deps.commitView) { + deps.commitView.updateState({ + stagedCount: fileStageStates.size, + lastError: undefined, + }); + } + const state = await store.load(deps.repoRoot); const totalFiles = state?.version === 1 @@ -153,6 +178,9 @@ export async function createDeps( fsStat, settings, prompt, + pendingStageOnSave, + bookmarkEditor, + bookmarkDeco, createChangelist, renameChangelist, restoreFilesToChangelist, @@ -161,6 +189,10 @@ export async function createDeps( loadOrInit, reconcile, restageAlreadyStaged, + setBookmark, + jumpToBookmark, + clearBookmark, + clearAllBookmarks, treeProvider, treeView, deco, @@ -170,7 +202,6 @@ export async function createDeps( closeDiffTabs, coordinator, newFileHandler, - pendingStageOnSave, conventionalCommits: conventionalCommitsAdapter, async listRepoRoots(): Promise { @@ -212,4 +243,4 @@ export async function createDeps( }; return deps; -} \ No newline at end of file +} diff --git a/src/app/types.ts b/src/app/types.ts index 350cdd8..f4872f3 100644 --- a/src/app/types.ts +++ b/src/app/types.ts @@ -1,27 +1,33 @@ import * as vscode from "vscode"; import { GitCliClient } from "../adapters/git/gitCliClient"; import { WorkspaceStateStore } from "../adapters/storage/workspaceStateStore"; +import { VsCodeBookmarkEditor } from "../adapters/vscode/bookmarkEditor"; +import { ConventionalCommitsAdapter } from "../adapters/vscode/conventionalCommitsAdapter"; +import { DiffTabTracker } from "../adapters/vscode/diffTabTracker"; import { VsCodeFsStat } from "../adapters/vscode/fsStat"; +import { PendingStageOnSave } from "../adapters/vscode/pendingStageOnSave"; import { VsCodePrompt } from "../adapters/vscode/prompt"; import { VsCodeSettings } from "../adapters/vscode/settings"; +import { RefreshCoordinator } from "../core/refresh/refreshCoordinator"; +import { ClearAllBookmarks } from "../usecases/bookmark/clearAllBookmarks"; +import { ClearBookmark } from "../usecases/bookmark/clearBookmark"; +import { JumpToBookmark } from "../usecases/bookmark/jumpToBookmark"; +import { SetBookmark } from "../usecases/bookmark/setBookmark"; +import { CloseDiffTabs } from "../usecases/closeDiffTabs"; import { CreateChangelist } from "../usecases/createChangelist"; import { DeleteChangelist } from "../usecases/deleteChangelist"; -import { RenameChangelist } from "../usecases/renameChangelist"; +import { HandleNewFilesCreated } from "../usecases/handleNewFilesCreated"; import { LoadOrInitState } from "../usecases/loadOrInitState"; import { MoveFilesToChangelist } from "../usecases/moveFilesToChangelist"; import { ReconcileWithGitStatus } from "../usecases/reconcileWithGitStatus"; +import { RenameChangelist } from "../usecases/renameChangelist"; +import { RestageAlreadyStaged } from "../usecases/restageAlreadyStaged"; +import { RestoreFilesToChangelist } from "../usecases/stash/restoreFilesToChangelist"; import { ChangelistTreeProvider } from "../views/changelistTreeProvider"; import { CommitViewProvider } from "../views/commitViewProvider"; import { StashesTreeProvider } from "../views/stash/stashesTreeProvider"; import { WorklistDecorationProvider } from "../views/worklistDecorationProvider"; -import { ConventionalCommitsAdapter } from "../adapters/vscode/conventionalCommitsAdapter"; -import { DiffTabTracker } from "../adapters/vscode/diffTabTracker"; -import { PendingStageOnSave } from "../adapters/vscode/pendingStageOnSave"; -import { RefreshCoordinator } from "../core/refresh/refreshCoordinator"; -import { CloseDiffTabs } from "../usecases/closeDiffTabs"; -import { HandleNewFilesCreated } from "../usecases/handleNewFilesCreated"; -import { RestageAlreadyStaged } from "../usecases/restageAlreadyStaged"; -import { RestoreFilesToChangelist } from "../usecases/stash/restoreFilesToChangelist"; +import { BookmarkDecorationProvider } from "../views/bookmark/bookmarkDecorationProvider"; export type GroupArg = { list: { id: string; name: string; files: string[] }; @@ -40,6 +46,8 @@ export type Deps = { settings: VsCodeSettings; prompt: VsCodePrompt; pendingStageOnSave: PendingStageOnSave; + bookmarkEditor: VsCodeBookmarkEditor; + bookmarkDeco:BookmarkDecorationProvider; createChangelist: CreateChangelist; renameChangelist: RenameChangelist; @@ -50,6 +58,11 @@ export type Deps = { reconcile: ReconcileWithGitStatus; restageAlreadyStaged: RestageAlreadyStaged; + setBookmark: SetBookmark; + jumpToBookmark: JumpToBookmark; + clearBookmark: ClearBookmark; + clearAllBookmarks: ClearAllBookmarks; + treeProvider: ChangelistTreeProvider; treeView: vscode.TreeView; deco: WorklistDecorationProvider; diff --git a/src/core/bookmark/bookmark.ts b/src/core/bookmark/bookmark.ts new file mode 100644 index 0000000..c639961 --- /dev/null +++ b/src/core/bookmark/bookmark.ts @@ -0,0 +1,33 @@ +export const BOOKMARK_SLOTS = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const; + +export type BookmarkSlot = (typeof BOOKMARK_SLOTS)[number]; + +export interface BookmarkTarget { + repoRelativePath: string; + line: number; + column: number; +} + +export interface BookmarkEntry { + slot: BookmarkSlot; + target: BookmarkTarget; +} + +export function isValidBookmarkSlot(value: number): value is BookmarkSlot { + return BOOKMARK_SLOTS.includes(value as BookmarkSlot); +} + +export function isSameBookmarkTarget( + a: BookmarkTarget, + b: BookmarkTarget, +): boolean { + return ( + a.repoRelativePath === b.repoRelativePath && + a.line === b.line && + a.column === b.column + ); +} + +export function formatBookmarkTarget(target: BookmarkTarget): string { + return `${target.repoRelativePath}:${target.line + 1}:${target.column + 1}`; +} diff --git a/src/core/bookmark/bookmarkRepository.ts b/src/core/bookmark/bookmarkRepository.ts new file mode 100644 index 0000000..4e3e7b6 --- /dev/null +++ b/src/core/bookmark/bookmarkRepository.ts @@ -0,0 +1,12 @@ +import type { BookmarkEntry, BookmarkSlot } from "./bookmark"; + +export interface BookmarkRepository { + getAll(repoRoot: string): Promise; + getBySlot( + repoRoot: string, + slot: BookmarkSlot, + ): Promise; + set(repoRoot: string, entry: BookmarkEntry): Promise; + clear(repoRoot: string, slot: BookmarkSlot): Promise; + clearAll(repoRoot: string): Promise; +} \ No newline at end of file diff --git a/src/extension.ts b/src/extension.ts index 12c08c5..a76b1d4 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -10,6 +10,7 @@ import { registerRefresh } from "./registration/registerRefresh"; import { registerRepoStatusBar } from "./registration/registerRepoStatusBar"; import { registerStash } from "./registration/registerStash"; import { registerViews } from "./registration/registerViews"; +import { registerBookmarkDecorations } from "./registration/registerBookmarkDecorations"; export async function activate(context: vscode.ExtensionContext) { @@ -34,6 +35,7 @@ export async function activate(context: vscode.ExtensionContext) { registerStash(deps); registerEvents(deps); registerRepoStatusBar(deps); + registerBookmarkDecorations(deps); } export function deactivate() {} diff --git a/src/registration/registerBookmarkDecorations.ts b/src/registration/registerBookmarkDecorations.ts new file mode 100644 index 0000000..de793bf --- /dev/null +++ b/src/registration/registerBookmarkDecorations.ts @@ -0,0 +1,45 @@ +import * as vscode from "vscode"; +import type { Deps } from "../app/types"; + +export function registerBookmarkDecorations(deps: Deps): void { + const { context } = deps; + + context.subscriptions.push(deps.bookmarkDeco); + + context.subscriptions.push( + vscode.window.onDidChangeActiveTextEditor(async () => { + await deps.bookmarkDeco.refreshVisibleEditors(); + }), + ); + + context.subscriptions.push( + vscode.window.onDidChangeVisibleTextEditors(async () => { + await deps.bookmarkDeco.refreshVisibleEditors(); + }), + ); + + context.subscriptions.push( + vscode.workspace.onDidChangeTextDocument(async (event) => { + const active = vscode.window.activeTextEditor; + if (!active) { + return; + } + + if (event.document.uri.toString() !== active.document.uri.toString()) { + return; + } + + await deps.bookmarkDeco.refreshActiveEditor(); + }), + ); + + context.subscriptions.push( + deps.onDidChangeRepoRoot(async (repoRoot) => { + deps.bookmarkDeco.setRepoRoot(repoRoot); + await deps.bookmarkDeco.refreshVisibleEditors(); + }), + ); + + // important: initial paint + void deps.bookmarkDeco.refreshVisibleEditors(); +} \ No newline at end of file diff --git a/src/registration/registerCommands.ts b/src/registration/registerCommands.ts index 2979254..4d30efd 100644 --- a/src/registration/registerCommands.ts +++ b/src/registration/registerCommands.ts @@ -9,8 +9,13 @@ import { GitShowContentProvider } from "../adapters/vscode/gitShowContentProvide import { SystemChangelist } from "../core/changelist/systemChangelist"; import { stageChangelistAll } from "../usecases/stageChangelistAll"; import { unstageChangelistAll } from "../usecases/unstageChangelistAll"; -import { openPushPreviewPanel } from "../views/pushPreviewPanel"; import { buildPatchForLineRange } from "../utils/patchBuilder"; +import { openPushPreviewPanel } from "../views/pushPreviewPanel"; + +import { + isValidBookmarkSlot, + type BookmarkSlot, +} from "../core/bookmark/bookmark"; export function registerCommands(deps: Deps) { const { context } = deps; @@ -185,6 +190,73 @@ export function registerCommands(deps: Deps) { return picked ? { id: picked.id, name: picked.label } : undefined; } + function toBookmarkSlot(value: number): BookmarkSlot { + if (!isValidBookmarkSlot(value)) { + throw new Error(`Invalid bookmark slot: ${value}`); + } + return value; + } + + function getBookmarkTargetFromArg(arg: any) { + if (typeof arg?.repoRelativePath === "string") { + const rel = normalizeRepoRelPath(arg.repoRelativePath); + if (!rel) { + return undefined; + } + + const editorTarget = getBookmarkTargetFromEditor(); + if ( + editorTarget && + normalizeRepoRelPath(editorTarget.repoRelativePath) === rel + ) { + return editorTarget; + } + + return { + repoRelativePath: rel, + line: 0, + column: 0, + }; + } + + const uri: vscode.Uri | undefined = + arg instanceof vscode.Uri + ? arg + : arg?.resourceUri instanceof vscode.Uri + ? arg.resourceUri + : undefined; + + if (uri?.scheme === "file") { + const targetFromUri = deps.bookmarkEditor.getTargetFromFsPath( + deps.repoRoot, + uri.fsPath, + 0, + 0, + ); + + if (!targetFromUri) { + return undefined; + } + + const editorTarget = getBookmarkTargetFromEditor(); + if ( + editorTarget && + normalizeRepoRelPath(editorTarget.repoRelativePath) === + normalizeRepoRelPath(targetFromUri.repoRelativePath) + ) { + return editorTarget; + } + + return targetFromUri; + } + + return undefined; + } + + function getBookmarkTargetFromEditor() { + return deps.bookmarkEditor.getActiveEditorTarget(deps.repoRoot); + } + context.subscriptions.push( vscode.commands.registerCommand( "gitWorklists.moveFileToChangelist", @@ -794,4 +866,115 @@ export function registerCommands(deps: Deps) { } }), ); + + for (let i = 1; i <= 9; i += 1) { + const slot = toBookmarkSlot(i); + + context.subscriptions.push( + vscode.commands.registerCommand( + `gitWorklists.bookmark.set${i}`, + async () => { + try { + const target = getBookmarkTargetFromEditor(); + if (!target) { + await deps.prompt.showWarning( + "No active editor bookmark target found.", + ); + return; + } + + await deps.setBookmark.run({ + repoRoot: deps.repoRoot, + target, + slot, + }); + await deps.bookmarkDeco.refreshVisibleEditors(); + } catch (e: any) { + console.error(e); + vscode.window.showErrorMessage(String(e?.message ?? e)); + } + }, + ), + + vscode.commands.registerCommand( + `gitWorklists.bookmark.jump${i}`, + async () => { + try { + await deps.jumpToBookmark.run(deps.repoRoot, slot); + } catch (e: any) { + console.error(e); + vscode.window.showErrorMessage(String(e?.message ?? e)); + } + }, + ), + + vscode.commands.registerCommand( + `gitWorklists.bookmark.clear${i}`, + async () => { + try { + await deps.clearBookmark.run(deps.repoRoot, slot); + } catch (e: any) { + console.error(e); + vscode.window.showErrorMessage(String(e?.message ?? e)); + } + }, + ), + ); + } + + context.subscriptions.push( + vscode.commands.registerCommand( + "gitWorklists.bookmark.set", + async (node: any) => { + try { + const targetFromArg = getBookmarkTargetFromArg(node); + const target = targetFromArg ?? getBookmarkTargetFromEditor(); + + if (!target) { + await deps.prompt.showWarning( + "Could not resolve a bookmark target.", + ); + return; + } + + await deps.setBookmark.run({ + repoRoot: deps.repoRoot, + target, + }); + + if (targetFromArg) { + await deps.bookmarkEditor.openTarget(deps.repoRoot, target); + } + + await deps.bookmarkDeco.refreshVisibleEditors(); + } catch (e: any) { + console.error(e); + vscode.window.showErrorMessage(String(e?.message ?? e)); + } + }, + ), + + vscode.commands.registerCommand("gitWorklists.bookmark.clear", async () => { + try { + await deps.clearBookmark.run(deps.repoRoot); + await deps.bookmarkDeco.refreshVisibleEditors(); + } catch (e: any) { + console.error(e); + vscode.window.showErrorMessage(String(e?.message ?? e)); + } + }), + + vscode.commands.registerCommand( + "gitWorklists.bookmark.clearAll", + async () => { + try { + await deps.clearAllBookmarks.run(deps.repoRoot); + await deps.bookmarkDeco.refreshVisibleEditors(); + } catch (e: any) { + console.error(e); + vscode.window.showErrorMessage(String(e?.message ?? e)); + } + }, + ), + ); } diff --git a/src/test/unit/adapters/storage/workspaceStateStore.test.ts b/src/test/unit/adapters/storage/workspaceStateStore.test.ts index be2db45..00097b6 100644 --- a/src/test/unit/adapters/storage/workspaceStateStore.test.ts +++ b/src/test/unit/adapters/storage/workspaceStateStore.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; import { WorkspaceStateStore, type PersistedState, @@ -95,4 +95,385 @@ describe("WorkspaceStateStore", () => { await store.clearSelectedFiles("/repo"); expect(store.getSelectedFiles("/repo")).toEqual(new Set()); }); + + it("getAll returns empty array when no bookmarks exist", async () => { + const mem = makeMemento(); + const store = new WorkspaceStateStore(mem); + + await expect(store.getAll("/repo")).resolves.toEqual([]); + }); + + it("set stores bookmarks under a per-repo key", async () => { + const mem = makeMemento(); + const store = new WorkspaceStateStore(mem); + + await store.set("/repo", { + slot: 1, + target: { + repoRelativePath: "src/a.ts", + line: 10, + column: 2, + }, + }); + + expect(mem.get("git-worklists.bookmarks.v1:/repo")).toEqual({ + version: 1, + entries: [ + { + slot: 1, + target: { + repoRelativePath: "src/a.ts", + line: 10, + column: 2, + }, + }, + ], + }); + + expect(mem.get("git-worklists.bookmarks.v1:/other")).toBeUndefined(); + }); + + it("getAll returns bookmarks sorted by slot", async () => { + const mem = makeMemento(); + const store = new WorkspaceStateStore(mem); + + await store.set("/repo", { + slot: 3, + target: { + repoRelativePath: "src/c.ts", + line: 3, + column: 3, + }, + }); + + await store.set("/repo", { + slot: 1, + target: { + repoRelativePath: "src/a.ts", + line: 1, + column: 1, + }, + }); + + await store.set("/repo", { + slot: 2, + target: { + repoRelativePath: "src/b.ts", + line: 2, + column: 2, + }, + }); + + await expect(store.getAll("/repo")).resolves.toEqual([ + { + slot: 1, + target: { + repoRelativePath: "src/a.ts", + line: 1, + column: 1, + }, + }, + { + slot: 2, + target: { + repoRelativePath: "src/b.ts", + line: 2, + column: 2, + }, + }, + { + slot: 3, + target: { + repoRelativePath: "src/c.ts", + line: 3, + column: 3, + }, + }, + ]); + }); + + it("set replaces an existing bookmark in the same slot", async () => { + const mem = makeMemento(); + const store = new WorkspaceStateStore(mem); + + await store.set("/repo", { + slot: 1, + target: { + repoRelativePath: "src/old.ts", + line: 1, + column: 0, + }, + }); + + await store.set("/repo", { + slot: 1, + target: { + repoRelativePath: "src/new.ts", + line: 9, + column: 4, + }, + }); + + await expect(store.getAll("/repo")).resolves.toEqual([ + { + slot: 1, + target: { + repoRelativePath: "src/new.ts", + line: 9, + column: 4, + }, + }, + ]); + }); + + it("getBySlot returns the matching bookmark", async () => { + const mem = makeMemento(); + const store = new WorkspaceStateStore(mem); + + await store.set("/repo", { + slot: 2, + target: { + repoRelativePath: "src/b.ts", + line: 5, + column: 6, + }, + }); + + await expect(store.getBySlot("/repo", 2)).resolves.toEqual({ + slot: 2, + target: { + repoRelativePath: "src/b.ts", + line: 5, + column: 6, + }, + }); + }); + + it("getBySlot returns undefined when the slot does not exist", async () => { + const mem = makeMemento(); + const store = new WorkspaceStateStore(mem); + + await store.set("/repo", { + slot: 1, + target: { + repoRelativePath: "src/a.ts", + line: 0, + column: 0, + }, + }); + + await expect(store.getBySlot("/repo", 9)).resolves.toBeUndefined(); + }); + + it("clear removes only the given bookmark slot", async () => { + const mem = makeMemento(); + const store = new WorkspaceStateStore(mem); + + await store.set("/repo", { + slot: 1, + target: { + repoRelativePath: "src/a.ts", + line: 0, + column: 0, + }, + }); + + await store.set("/repo", { + slot: 2, + target: { + repoRelativePath: "src/b.ts", + line: 1, + column: 1, + }, + }); + + await store.clear("/repo", 1); + + await expect(store.getAll("/repo")).resolves.toEqual([ + { + slot: 2, + target: { + repoRelativePath: "src/b.ts", + line: 1, + column: 1, + }, + }, + ]); + }); + + it("clearAll removes all bookmarks for one repo only", async () => { + const mem = makeMemento(); + const store = new WorkspaceStateStore(mem); + + await store.set("/repo-a", { + slot: 1, + target: { + repoRelativePath: "src/a.ts", + line: 0, + column: 0, + }, + }); + + await store.set("/repo-b", { + slot: 2, + target: { + repoRelativePath: "src/b.ts", + line: 2, + column: 2, + }, + }); + + await store.clearAll("/repo-a"); + + await expect(store.getAll("/repo-a")).resolves.toEqual([]); + await expect(store.getAll("/repo-b")).resolves.toEqual([ + { + slot: 2, + target: { + repoRelativePath: "src/b.ts", + line: 2, + column: 2, + }, + }, + ]); + }); + + it("normalizes bookmark repoRelativePath when storing", async () => { + const mem = makeMemento(); + const store = new WorkspaceStateStore(mem); + + await store.set("/repo", { + slot: 1, + target: { + repoRelativePath: "src\\nested\\file.ts", + line: 7, + column: 8, + }, + }); + + await expect(store.getAll("/repo")).resolves.toEqual([ + { + slot: 1, + target: { + repoRelativePath: "src/nested/file.ts", + line: 7, + column: 8, + }, + }, + ]); + }); + + it("normalizes bookmark repoRelativePath when loading preexisting persisted data", async () => { + const mem = makeMemento({ + "git-worklists.bookmarks.v1:/repo": { + version: 1, + entries: [ + { + slot: 1, + target: { + repoRelativePath: "src\\a.ts", + line: 3, + column: 4, + }, + }, + { + slot: 2, + target: { + repoRelativePath: "nested\\b.ts", + line: 5, + column: 6, + }, + }, + ], + }, + }); + + const store = new WorkspaceStateStore(mem); + + await expect(store.getAll("/repo")).resolves.toEqual([ + { + slot: 1, + target: { + repoRelativePath: "src/a.ts", + line: 3, + column: 4, + }, + }, + { + slot: 2, + target: { + repoRelativePath: "nested/b.ts", + line: 5, + column: 6, + }, + }, + ]); + }); + + it("keeps bookmarks isolated per repo", async () => { + const mem = makeMemento(); + const store = new WorkspaceStateStore(mem); + + await store.set("/repo-a", { + slot: 1, + target: { + repoRelativePath: "a.ts", + line: 0, + column: 0, + }, + }); + + await store.set("/repo-b", { + slot: 1, + target: { + repoRelativePath: "b.ts", + line: 1, + column: 1, + }, + }); + + await expect(store.getAll("/repo-a")).resolves.toEqual([ + { + slot: 1, + target: { + repoRelativePath: "a.ts", + line: 0, + column: 0, + }, + }, + ]); + + await expect(store.getAll("/repo-b")).resolves.toEqual([ + { + slot: 1, + target: { + repoRelativePath: "b.ts", + line: 1, + column: 1, + }, + }, + ]); + }); + + it("returns empty bookmarks when persisted bookmark version is invalid", async () => { + const mem = makeMemento({ + "git-worklists.bookmarks.v1:/repo": { + version: 999, + entries: [ + { + slot: 1, + target: { + repoRelativePath: "a.ts", + line: 0, + column: 0, + }, + }, + ], + }, + }); + + const store = new WorkspaceStateStore(mem); + + await expect(store.getAll("/repo")).resolves.toEqual([]); + }); }); diff --git a/src/test/unit/adapters/vscode/prompt.test.ts b/src/test/unit/adapters/vscode/prompt.test.ts index b06088a..39a71be 100644 --- a/src/test/unit/adapters/vscode/prompt.test.ts +++ b/src/test/unit/adapters/vscode/prompt.test.ts @@ -3,6 +3,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; const mocks = vi.hoisted(() => { return { showInformationMessage: vi.fn(), + showWarningMessage: vi.fn(), + showQuickPick: vi.fn(), }; }); @@ -10,6 +12,8 @@ vi.mock("vscode", () => { return { window: { showInformationMessage: mocks.showInformationMessage, + showWarningMessage: mocks.showWarningMessage, + showQuickPick: mocks.showQuickPick, }, }; }); @@ -18,6 +22,8 @@ import { VsCodePrompt } from "../../../../adapters/vscode/prompt"; beforeEach(() => { mocks.showInformationMessage.mockReset(); + mocks.showWarningMessage.mockReset(); + mocks.showQuickPick.mockReset(); }); describe("VsCodePrompt.confirmAddNewFiles", () => { @@ -74,3 +80,203 @@ describe("VsCodePrompt.confirmAddNewFiles", () => { expect(res).toBe("dismiss"); }); }); + +describe("VsCodePrompt.pickBookmarkSlot", () => { + it("shows quick pick for bookmark slots and returns picked slot", async () => { + mocks.showQuickPick.mockResolvedValue({ + label: "Bookmark 3", + description: "Slot 3", + slot: 3, + }); + + const prompt = new VsCodePrompt(); + const res = await prompt.pickBookmarkSlot(); + + expect(res).toBe(3); + expect(mocks.showQuickPick).toHaveBeenCalledTimes(1); + + const [items, options] = mocks.showQuickPick.mock.calls[0] as [ + Array<{ label: string; description: string; slot: number }>, + { placeHolder: string; ignoreFocusOut: boolean }, + ]; + + expect(items).toHaveLength(9); + expect(items[0]).toEqual({ + label: "Bookmark 1", + description: "Slot 1", + slot: 1, + }); + expect(items[8]).toEqual({ + label: "Bookmark 9", + description: "Slot 9", + slot: 9, + }); + + expect(options).toEqual({ + placeHolder: "Select bookmark slot", + ignoreFocusOut: true, + }); + }); + + it("returns undefined when quick pick is dismissed", async () => { + mocks.showQuickPick.mockResolvedValue(undefined); + + const prompt = new VsCodePrompt(); + const res = await prompt.pickBookmarkSlot(); + + expect(res).toBeUndefined(); + }); +}); + +describe("VsCodePrompt.confirmBookmarkOverwrite", () => { + it("shows modal warning and returns true when user confirms replace", async () => { + mocks.showWarningMessage.mockResolvedValue("Replace"); + + const prompt = new VsCodePrompt(); + const res = await prompt.confirmBookmarkOverwrite( + { + slot: 1, + target: { + repoRelativePath: "src/old.ts", + line: 4, + column: 1, + }, + }, + { + slot: 1, + target: { + repoRelativePath: "src/new.ts", + line: 10, + column: 3, + }, + }, + ); + + expect(res).toBe(true); + expect(mocks.showWarningMessage).toHaveBeenCalledTimes(1); + + const [message, options, action] = mocks.showWarningMessage.mock + .calls[0] as [string, { modal: boolean; detail: string }, string]; + + expect(message).toBe("Bookmark 1 is already set."); + expect(options.modal).toBe(true); + expect(options.detail).toContain("Current: src/old.ts:5:2"); + expect(options.detail).toContain("New: src/new.ts:11:4"); + expect(options.detail).toContain("Do you want to replace it?"); + expect(action).toBe("Replace"); + }); + + it("returns false when user cancels overwrite", async () => { + mocks.showWarningMessage.mockResolvedValue("Cancel"); + + const prompt = new VsCodePrompt(); + const res = await prompt.confirmBookmarkOverwrite( + { + slot: 2, + target: { + repoRelativePath: "src/a.ts", + line: 0, + column: 0, + }, + }, + { + slot: 2, + target: { + repoRelativePath: "src/b.ts", + line: 1, + column: 1, + }, + }, + ); + + expect(res).toBe(false); + }); + + it("returns false when overwrite dialog is dismissed", async () => { + mocks.showWarningMessage.mockResolvedValue(undefined); + + const prompt = new VsCodePrompt(); + const res = await prompt.confirmBookmarkOverwrite( + { + slot: 2, + target: { + repoRelativePath: "src/a.ts", + line: 0, + column: 0, + }, + }, + { + slot: 2, + target: { + repoRelativePath: "src/b.ts", + line: 1, + column: 1, + }, + }, + ); + + expect(res).toBe(false); + }); +}); + +describe("VsCodePrompt.confirmClearAllBookmarks", () => { + it("shows modal warning and returns true when user confirms", async () => { + mocks.showWarningMessage.mockResolvedValue("Clear All"); + + const prompt = new VsCodePrompt(); + const res = await prompt.confirmClearAllBookmarks(3); + + expect(res).toBe(true); + expect(mocks.showWarningMessage).toHaveBeenCalledTimes(1); + + const [message, options, action] = mocks.showWarningMessage.mock + .calls[0] as [string, { modal: boolean; detail: string }, string]; + + expect(message).toBe("Clear all bookmarks?"); + expect(options).toEqual({ + modal: true, + detail: "This will remove 3 bookmark(s) for the current repository.", + }); + expect(action).toBe("Clear All"); + }); + + it("returns false when user cancels clear all", async () => { + mocks.showWarningMessage.mockResolvedValue("Cancel"); + + const prompt = new VsCodePrompt(); + const res = await prompt.confirmClearAllBookmarks(5); + + expect(res).toBe(false); + }); + + it("returns false when clear all dialog is dismissed", async () => { + mocks.showWarningMessage.mockResolvedValue(undefined); + + const prompt = new VsCodePrompt(); + const res = await prompt.confirmClearAllBookmarks(1); + + expect(res).toBe(false); + }); +}); + +describe("VsCodePrompt.showInfo", () => { + it("forwards message to showInformationMessage", async () => { + mocks.showInformationMessage.mockResolvedValue(undefined); + + const prompt = new VsCodePrompt(); + await prompt.showInfo("Saved"); + + expect(mocks.showInformationMessage).toHaveBeenCalledWith("Saved"); + }); +}); + +describe("VsCodePrompt.showWarning", () => { + it("forwards message to showWarningMessage", async () => { + mocks.showWarningMessage.mockResolvedValue(undefined); + + const prompt = new VsCodePrompt(); + await prompt.showWarning("Warning"); + + expect(mocks.showWarningMessage).toHaveBeenCalledWith("Warning"); + }); +}); diff --git a/src/test/unit/core/bookmark/bookmark.test.ts b/src/test/unit/core/bookmark/bookmark.test.ts new file mode 100644 index 0000000..d57a912 --- /dev/null +++ b/src/test/unit/core/bookmark/bookmark.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { + BOOKMARK_SLOTS, + formatBookmarkTarget, + isSameBookmarkTarget, + isValidBookmarkSlot, + type BookmarkTarget, +} from "../../../../core/bookmark/bookmark"; + +describe("core/bookmark/bookmark", () => { + describe("BOOKMARK_SLOTS", () => { + it("contains slots 1 through 9", () => { + expect(BOOKMARK_SLOTS).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]); + }); + }); + + describe("isValidBookmarkSlot", () => { + it("returns true for valid bookmark slots", () => { + for (const slot of BOOKMARK_SLOTS) { + expect(isValidBookmarkSlot(slot)).toBe(true); + } + }); + + it("returns false for values below the valid range", () => { + expect(isValidBookmarkSlot(0)).toBe(false); + expect(isValidBookmarkSlot(-1)).toBe(false); + expect(isValidBookmarkSlot(-100)).toBe(false); + }); + + it("returns false for values above the valid range", () => { + expect(isValidBookmarkSlot(10)).toBe(false); + expect(isValidBookmarkSlot(99)).toBe(false); + }); + + it("returns false for non-slot integers", () => { + expect(isValidBookmarkSlot(11)).toBe(false); + expect(isValidBookmarkSlot(42)).toBe(false); + }); + + it("returns false for non-integer numbers", () => { + expect(isValidBookmarkSlot(1.5)).toBe(false); + expect(isValidBookmarkSlot(8.1)).toBe(false); + }); + }); + + describe("isSameBookmarkTarget", () => { + const base: BookmarkTarget = { + repoRelativePath: "src/bookmark.ts", + line: 10, + column: 3, + }; + + it("returns true when repoRelativePath, line, and column are all equal", () => { + expect( + isSameBookmarkTarget(base, { + repoRelativePath: "src/bookmark.ts", + line: 10, + column: 3, + }), + ).toBe(true); + }); + + it("returns false when repoRelativePath differs", () => { + expect( + isSameBookmarkTarget(base, { + repoRelativePath: "src/other.ts", + line: 10, + column: 3, + }), + ).toBe(false); + }); + + it("returns false when line differs", () => { + expect( + isSameBookmarkTarget(base, { + repoRelativePath: "src/bookmark.ts", + line: 11, + column: 3, + }), + ).toBe(false); + }); + + it("returns false when column differs", () => { + expect( + isSameBookmarkTarget(base, { + repoRelativePath: "src/bookmark.ts", + line: 10, + column: 4, + }), + ).toBe(false); + }); + + it("returns false when all fields differ", () => { + expect( + isSameBookmarkTarget(base, { + repoRelativePath: "README.md", + line: 0, + column: 0, + }), + ).toBe(false); + }); + }); + + describe("formatBookmarkTarget", () => { + it("formats repoRelativePath, line, and column using 1-based display values", () => { + expect( + formatBookmarkTarget({ + repoRelativePath: "src/bookmark.ts", + line: 0, + column: 0, + }), + ).toBe("src/bookmark.ts:1:1"); + }); + + it("adds 1 to both line and column", () => { + expect( + formatBookmarkTarget({ + repoRelativePath: "src/bookmark.ts", + line: 10, + column: 3, + }), + ).toBe("src/bookmark.ts:11:4"); + }); + + it("preserves the repo-relative path exactly", () => { + expect( + formatBookmarkTarget({ + repoRelativePath: "nested/path/file.ts", + line: 4, + column: 8, + }), + ).toBe("nested/path/file.ts:5:9"); + }); + + it("works with paths at repo root", () => { + expect( + formatBookmarkTarget({ + repoRelativePath: "pom.xml", + line: 2, + column: 1, + }), + ).toBe("pom.xml:3:2"); + }); + }); +}); diff --git a/src/test/unit/usecases/bookmark/clearAllBookmarks.test.ts b/src/test/unit/usecases/bookmark/clearAllBookmarks.test.ts new file mode 100644 index 0000000..eeeaf02 --- /dev/null +++ b/src/test/unit/usecases/bookmark/clearAllBookmarks.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ClearAllBookmarks } from "../../../../usecases/bookmark/clearAllBookmarks"; +import type { BookmarkRepository } from "../../../../core/bookmark/bookmarkRepository"; + +describe("ClearAllBookmarks", () => { + const repoRoot = "/repo"; + + let bookmarks: BookmarkRepository; + let prompt: { + confirmClearAllBookmarks: ReturnType; + showInfo: ReturnType; + }; + + beforeEach(() => { + bookmarks = { + getAll: vi.fn(), + getBySlot: vi.fn(), + set: vi.fn(), + clear: vi.fn(), + clearAll: vi.fn(), + }; + + prompt = { + confirmClearAllBookmarks: vi.fn(), + showInfo: vi.fn().mockResolvedValue(undefined), + }; + }); + + it("shows info when there are no bookmarks", async () => { + vi.mocked(bookmarks.getAll).mockResolvedValue([]); + + const usecase = new ClearAllBookmarks(bookmarks, prompt); + + await usecase.run(repoRoot); + + expect(prompt.showInfo).toHaveBeenCalled(); + expect(bookmarks.clearAll).not.toHaveBeenCalled(); + }); + + it("clears all when confirmed", async () => { + vi.mocked(bookmarks.getAll).mockResolvedValue([ + { slot: 1, target: { repoRelativePath: "a.ts", line: 0, column: 0 } }, + ]); + prompt.confirmClearAllBookmarks.mockResolvedValue(true); + + const usecase = new ClearAllBookmarks(bookmarks, prompt); + + await usecase.run(repoRoot); + + expect(bookmarks.clearAll).toHaveBeenCalledWith(repoRoot); + }); + + it("does not clear all when confirmation is rejected", async () => { + vi.mocked(bookmarks.getAll).mockResolvedValue([ + { slot: 1, target: { repoRelativePath: "a.ts", line: 0, column: 0 } }, + ]); + prompt.confirmClearAllBookmarks.mockResolvedValue(false); + + const usecase = new ClearAllBookmarks(bookmarks, prompt); + + await usecase.run(repoRoot); + + expect(bookmarks.clearAll).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/src/test/unit/usecases/bookmark/clearBookmark.test.ts b/src/test/unit/usecases/bookmark/clearBookmark.test.ts new file mode 100644 index 0000000..e227f56 --- /dev/null +++ b/src/test/unit/usecases/bookmark/clearBookmark.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ClearBookmark } from "../../../../usecases/bookmark/clearBookmark"; +import type { BookmarkRepository } from "../../../../core/bookmark/bookmarkRepository"; + +describe("ClearBookmark", () => { + const repoRoot = "/repo"; + + let bookmarks: BookmarkRepository; + let prompt: { + pickBookmarkSlot: ReturnType; + showInfo: ReturnType; + }; + + beforeEach(() => { + bookmarks = { + getAll: vi.fn(), + getBySlot: vi.fn(), + set: vi.fn(), + clear: vi.fn(), + clearAll: vi.fn(), + }; + + prompt = { + pickBookmarkSlot: vi.fn(), + showInfo: vi.fn().mockResolvedValue(undefined), + }; + }); + + it("clears the given slot", async () => { + const usecase = new ClearBookmark(bookmarks, prompt); + + await usecase.run(repoRoot, 2); + + expect(bookmarks.clear).toHaveBeenCalledWith(repoRoot, 2); + }); + + it("uses picker when slot is omitted", async () => { + prompt.pickBookmarkSlot.mockResolvedValue(4); + + const usecase = new ClearBookmark(bookmarks, prompt); + + await usecase.run(repoRoot); + + expect(bookmarks.clear).toHaveBeenCalledWith(repoRoot, 4); + }); + + it("does nothing when picker is canceled", async () => { + prompt.pickBookmarkSlot.mockResolvedValue(undefined); + + const usecase = new ClearBookmark(bookmarks, prompt); + + await usecase.run(repoRoot); + + expect(bookmarks.clear).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/src/test/unit/usecases/bookmark/jumpToBookmark.test.ts b/src/test/unit/usecases/bookmark/jumpToBookmark.test.ts new file mode 100644 index 0000000..815b868 --- /dev/null +++ b/src/test/unit/usecases/bookmark/jumpToBookmark.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as fs from "fs/promises"; +import { JumpToBookmark } from "../../../../usecases/bookmark/jumpToBookmark"; +import type { BookmarkRepository } from "../../../../core/bookmark/bookmarkRepository"; + +vi.mock("fs/promises", () => ({ + stat: vi.fn(), +})); + +describe("JumpToBookmark", () => { + const repoRoot = "/repo"; + + let bookmarks: BookmarkRepository; + let editor: { openTarget: ReturnType }; + let prompt: { + showInfo: ReturnType; + showWarning: ReturnType; + }; + + beforeEach(() => { + bookmarks = { + getAll: vi.fn(), + getBySlot: vi.fn(), + set: vi.fn(), + clear: vi.fn(), + clearAll: vi.fn(), + }; + + editor = { + openTarget: vi.fn().mockResolvedValue(undefined), + }; + + prompt = { + showInfo: vi.fn().mockResolvedValue(undefined), + showWarning: vi.fn().mockResolvedValue(undefined), + }; + }); + + it("shows info when bookmark slot is empty", async () => { + vi.mocked(bookmarks.getBySlot).mockResolvedValue(undefined); + + const usecase = new JumpToBookmark(bookmarks, editor as any, prompt); + + await usecase.run(repoRoot, 1); + + expect(prompt.showInfo).toHaveBeenCalled(); + expect(editor.openTarget).not.toHaveBeenCalled(); + }); + + it("opens the bookmarked target when file exists", async () => { + vi.mocked(bookmarks.getBySlot).mockResolvedValue({ + slot: 1, + target: { + repoRelativePath: "src/a.ts", + line: 10, + column: 2, + }, + }); + vi.mocked(fs.stat).mockResolvedValue({} as any); + + const usecase = new JumpToBookmark(bookmarks, editor as any, prompt); + + await usecase.run(repoRoot, 1); + + expect(editor.openTarget).toHaveBeenCalledWith(repoRoot, { + repoRelativePath: "src/a.ts", + line: 10, + column: 2, + }); + }); + + it("shows warning when bookmarked file is missing", async () => { + vi.mocked(bookmarks.getBySlot).mockResolvedValue({ + slot: 1, + target: { + repoRelativePath: "src/a.ts", + line: 10, + column: 2, + }, + }); + vi.mocked(fs.stat).mockRejectedValue(new Error("missing")); + + const usecase = new JumpToBookmark(bookmarks, editor as any, prompt); + + await usecase.run(repoRoot, 1); + + expect(prompt.showWarning).toHaveBeenCalled(); + expect(editor.openTarget).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/src/test/unit/usecases/bookmark/setBookmark.test.ts b/src/test/unit/usecases/bookmark/setBookmark.test.ts new file mode 100644 index 0000000..9ff3b44 --- /dev/null +++ b/src/test/unit/usecases/bookmark/setBookmark.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { SetBookmark } from "../../../../usecases/bookmark/setBookmark"; +import type { + BookmarkEntry, + BookmarkSlot, + BookmarkTarget, +} from "../../../../core/bookmark/bookmark"; +import type { BookmarkRepository } from "../../../../core/bookmark/bookmarkRepository"; + +function makeTarget( + repoRelativePath: string, + line = 0, + column = 0, +): BookmarkTarget { + return { repoRelativePath, line, column }; +} + +describe("SetBookmark", () => { + const repoRoot = "/repo"; + + let bookmarks: BookmarkRepository; + let prompt: { + pickBookmarkSlot: ReturnType; + confirmBookmarkOverwrite: ReturnType; + showInfo: ReturnType; + showWarning: ReturnType; + }; + + beforeEach(() => { + bookmarks = { + getAll: vi.fn(), + getBySlot: vi.fn(), + set: vi.fn(), + clear: vi.fn(), + clearAll: vi.fn(), + }; + + prompt = { + pickBookmarkSlot: vi.fn(), + confirmBookmarkOverwrite: vi.fn(), + showInfo: vi.fn().mockResolvedValue(undefined), + showWarning: vi.fn().mockResolvedValue(undefined), + }; + }); + + it("saves a bookmark when slot is empty", async () => { + vi.mocked(bookmarks.getBySlot).mockResolvedValue(undefined); + + const usecase = new SetBookmark(bookmarks, prompt); + + await usecase.run({ + repoRoot, + slot: 1, + target: makeTarget("src/a.ts", 10, 2), + }); + + expect(bookmarks.set).toHaveBeenCalledWith(repoRoot, { + slot: 1, + target: makeTarget("src/a.ts", 10, 2), + }); + expect(prompt.confirmBookmarkOverwrite).not.toHaveBeenCalled(); + }); + + it("does not overwrite when target is the same", async () => { + const existing: BookmarkEntry = { + slot: 1, + target: makeTarget("src/a.ts", 10, 2), + }; + + vi.mocked(bookmarks.getBySlot).mockResolvedValue(existing); + + const usecase = new SetBookmark(bookmarks, prompt); + + await usecase.run({ + repoRoot, + slot: 1, + target: makeTarget("src/a.ts", 10, 2), + }); + + expect(bookmarks.set).not.toHaveBeenCalled(); + expect(prompt.confirmBookmarkOverwrite).not.toHaveBeenCalled(); + }); + + it("asks before overwriting an occupied slot", async () => { + const existing: BookmarkEntry = { + slot: 1, + target: makeTarget("src/old.ts", 1, 0), + }; + + vi.mocked(bookmarks.getBySlot).mockResolvedValue(existing); + prompt.confirmBookmarkOverwrite.mockResolvedValue(true); + + const usecase = new SetBookmark(bookmarks, prompt); + + await usecase.run({ + repoRoot, + slot: 1, + target: makeTarget("src/new.ts", 5, 1), + }); + + expect(prompt.confirmBookmarkOverwrite).toHaveBeenCalledWith(existing, { + slot: 1, + target: makeTarget("src/new.ts", 5, 1), + }); + expect(bookmarks.set).toHaveBeenCalledWith(repoRoot, { + slot: 1, + target: makeTarget("src/new.ts", 5, 1), + }); + }); + + it("does not overwrite when user rejects confirmation", async () => { + const existing: BookmarkEntry = { + slot: 1, + target: makeTarget("src/old.ts", 1, 0), + }; + + vi.mocked(bookmarks.getBySlot).mockResolvedValue(existing); + prompt.confirmBookmarkOverwrite.mockResolvedValue(false); + + const usecase = new SetBookmark(bookmarks, prompt); + + await usecase.run({ + repoRoot, + slot: 1, + target: makeTarget("src/new.ts", 5, 1), + }); + + expect(bookmarks.set).not.toHaveBeenCalled(); + }); + + it("uses picked slot when slot is omitted", async () => { + vi.mocked(bookmarks.getBySlot).mockResolvedValue(undefined); + prompt.pickBookmarkSlot.mockResolvedValue(3 satisfies BookmarkSlot); + + const usecase = new SetBookmark(bookmarks, prompt); + + await usecase.run({ + repoRoot, + target: makeTarget("src/a.ts", 3, 4), + }); + + expect(prompt.pickBookmarkSlot).toHaveBeenCalled(); + expect(bookmarks.set).toHaveBeenCalledWith(repoRoot, { + slot: 3, + target: makeTarget("src/a.ts", 3, 4), + }); + }); + + it("does nothing when slot picker is canceled", async () => { + prompt.pickBookmarkSlot.mockResolvedValue(undefined); + + const usecase = new SetBookmark(bookmarks, prompt); + + await usecase.run({ + repoRoot, + target: makeTarget("src/a.ts", 3, 4), + }); + + expect(bookmarks.getBySlot).not.toHaveBeenCalled(); + expect(bookmarks.set).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/src/test/unit/views/bookmark/bookmarkDecorationProvider.test.ts b/src/test/unit/views/bookmark/bookmarkDecorationProvider.test.ts new file mode 100644 index 0000000..b84f441 --- /dev/null +++ b/src/test/unit/views/bookmark/bookmarkDecorationProvider.test.ts @@ -0,0 +1,439 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("vscode", () => { + class MockRange { + constructor( + public readonly startLine: number, + public readonly startCharacter: number, + public readonly endLine: number, + public readonly endCharacter: number, + ) {} + } + + return { + window: { + visibleTextEditors: [], + activeTextEditor: undefined, + createTextEditorDecorationType: vi.fn(), + }, + Uri: { + joinPath: vi.fn(), + }, + Range: MockRange, + OverviewRulerLane: { + Left: 1, + }, + }; +}); + +import * as vscode from "vscode"; +import type { BookmarkEntry } from "../../../../core/bookmark/bookmark"; +import { BookmarkDecorationProvider } from "../../../../views/bookmark/bookmarkDecorationProvider"; + +function makeDecoration(slot: number) { + return { + slot, + dispose: vi.fn(), + }; +} + +function makeEditor(fsPath: string, scheme = "file", lineCount = 100) { + return { + document: { + uri: { + scheme, + fsPath, + }, + lineCount, + }, + setDecorations: vi.fn(), + }; +} + +function makeStore(entries: BookmarkEntry[]) { + return { + getAll: vi.fn().mockResolvedValue(entries), + }; +} + +describe("BookmarkDecorationProvider", () => { + const extensionUri = { path: "/ext" } as any; + + beforeEach(() => { + (vscode.window.visibleTextEditors as any[]).length = 0; + (vscode.window as any).activeTextEditor = undefined; + + vi.mocked(vscode.window.createTextEditorDecorationType).mockReset(); + vi.mocked(vscode.Uri.joinPath).mockReset(); + + vi.mocked(vscode.Uri.joinPath).mockImplementation( + (_base: unknown, ...segments: string[]) => + ({ + fsPath: segments.join("/"), + path: segments.join("/"), + toString: () => segments.join("/"), + }) as any, + ); + + vi.mocked(vscode.window.createTextEditorDecorationType).mockImplementation( + () => makeDecoration(0) as any, + ); + }); + + it("does nothing when repo root is not set", async () => { + const store = makeStore([ + { + slot: 1, + target: { + repoRelativePath: "src/a.ts", + line: 3, + column: 1, + }, + }, + ]); + + const editor = makeEditor("/repo/src/a.ts"); + (vscode.window.visibleTextEditors as any[]).push(editor); + + const provider = new BookmarkDecorationProvider( + store as any, + { extensionUri } as any, + ); + + await provider.refreshVisibleEditors(); + + expect(store.getAll).not.toHaveBeenCalled(); + expect(editor.setDecorations).not.toHaveBeenCalled(); + }); + + it("does nothing for non-file editors", async () => { + const store = makeStore([]); + const editor = makeEditor("/repo/src/a.ts", "git"); + (vscode.window.visibleTextEditors as any[]).push(editor); + + const provider = new BookmarkDecorationProvider( + store as any, + { extensionUri } as any, + ); + provider.setRepoRoot("/repo"); + + await provider.refreshVisibleEditors(); + + expect(store.getAll).not.toHaveBeenCalled(); + expect(editor.setDecorations).not.toHaveBeenCalled(); + }); + + it("does nothing for files outside the repo root", async () => { + const store = makeStore([]); + const editor = makeEditor("/other/src/a.ts"); + (vscode.window.visibleTextEditors as any[]).push(editor); + + const provider = new BookmarkDecorationProvider( + store as any, + { extensionUri } as any, + ); + provider.setRepoRoot("/repo"); + + await provider.refreshVisibleEditors(); + + expect(store.getAll).not.toHaveBeenCalled(); + expect(editor.setDecorations).not.toHaveBeenCalled(); + }); + + it("applies a decoration for a matching bookmark", async () => { + const decoration = makeDecoration(1); + vi.mocked(vscode.window.createTextEditorDecorationType).mockReturnValue( + decoration as any, + ); + + const store = makeStore([ + { + slot: 1, + target: { + repoRelativePath: "src/a.ts", + line: 10, + column: 2, + }, + }, + ]); + + const editor = makeEditor("/repo/src/a.ts", "file", 50); + (vscode.window.visibleTextEditors as any[]).push(editor); + + const provider = new BookmarkDecorationProvider( + store as any, + { extensionUri } as any, + ); + provider.setRepoRoot("/repo"); + + await provider.refreshVisibleEditors(); + + expect(store.getAll).toHaveBeenCalledWith("/repo"); + expect(vscode.Uri.joinPath).toHaveBeenCalledWith( + extensionUri, + "media", + "bookmarks", + "bookmark-1.svg", + ); + expect(vscode.window.createTextEditorDecorationType).toHaveBeenCalledTimes( + 1, + ); + expect(editor.setDecorations).toHaveBeenCalledTimes(1); + + const [usedDecoration, ranges] = editor.setDecorations.mock.calls[0]; + expect(usedDecoration).toBe(decoration); + expect(ranges).toHaveLength(1); + expect(ranges[0].hoverMessage).toBe("Bookmark 1"); + expect(ranges[0].range).toEqual(new (vscode.Range as any)(10, 0, 10, 0)); + }); + + it("does not apply decorations when no bookmarks match the current file", async () => { + const store = makeStore([ + { + slot: 1, + target: { + repoRelativePath: "src/other.ts", + line: 10, + column: 2, + }, + }, + ]); + + const editor = makeEditor("/repo/src/a.ts", "file", 50); + (vscode.window.visibleTextEditors as any[]).push(editor); + + const provider = new BookmarkDecorationProvider( + store as any, + { extensionUri } as any, + ); + provider.setRepoRoot("/repo"); + + await provider.refreshVisibleEditors(); + + expect(store.getAll).toHaveBeenCalledWith("/repo"); + expect(vscode.window.createTextEditorDecorationType).not.toHaveBeenCalled(); + expect(editor.setDecorations).not.toHaveBeenCalled(); + }); + + it("clamps negative bookmark lines to 0", async () => { + const decoration = makeDecoration(1); + vi.mocked(vscode.window.createTextEditorDecorationType).mockReturnValue( + decoration as any, + ); + + const store = makeStore([ + { + slot: 1, + target: { + repoRelativePath: "src/a.ts", + line: -5, + column: 0, + }, + }, + ]); + + const editor = makeEditor("/repo/src/a.ts", "file", 20); + (vscode.window.visibleTextEditors as any[]).push(editor); + + const provider = new BookmarkDecorationProvider( + store as any, + { extensionUri } as any, + ); + provider.setRepoRoot("/repo"); + + await provider.refreshVisibleEditors(); + + const [, ranges] = editor.setDecorations.mock.calls[0]; + expect(ranges[0].range).toEqual(new (vscode.Range as any)(0, 0, 0, 0)); + }); + + it("clamps bookmark lines larger than document line count", async () => { + const decoration = makeDecoration(1); + vi.mocked(vscode.window.createTextEditorDecorationType).mockReturnValue( + decoration as any, + ); + + const store = makeStore([ + { + slot: 1, + target: { + repoRelativePath: "src/a.ts", + line: 999, + column: 0, + }, + }, + ]); + + const editor = makeEditor("/repo/src/a.ts", "file", 7); + (vscode.window.visibleTextEditors as any[]).push(editor); + + const provider = new BookmarkDecorationProvider( + store as any, + { extensionUri } as any, + ); + provider.setRepoRoot("/repo"); + + await provider.refreshVisibleEditors(); + + const [, ranges] = editor.setDecorations.mock.calls[0]; + expect(ranges[0].range).toEqual(new (vscode.Range as any)(6, 0, 6, 0)); + }); + + it("reuses the same decoration instance for the same slot", async () => { + const decoration = makeDecoration(1); + vi.mocked(vscode.window.createTextEditorDecorationType).mockReturnValue( + decoration as any, + ); + + const store = makeStore([ + { + slot: 1, + target: { + repoRelativePath: "src/a.ts", + line: 1, + column: 0, + }, + }, + ]); + + const editorA = makeEditor("/repo/src/a.ts", "file", 20); + const editorB = makeEditor("/repo/src/a.ts", "file", 20); + (vscode.window.visibleTextEditors as any[]).push(editorA, editorB); + + const provider = new BookmarkDecorationProvider( + store as any, + { extensionUri } as any, + ); + provider.setRepoRoot("/repo"); + + await provider.refreshVisibleEditors(); + + expect(vscode.window.createTextEditorDecorationType).toHaveBeenCalledTimes( + 1, + ); + }); + + it("refreshActiveEditor refreshes only the active editor", async () => { + const decoration = makeDecoration(1); + vi.mocked(vscode.window.createTextEditorDecorationType).mockReturnValue( + decoration as any, + ); + + const store = makeStore([ + { + slot: 1, + target: { + repoRelativePath: "src/a.ts", + line: 2, + column: 0, + }, + }, + ]); + + const activeEditor = makeEditor("/repo/src/a.ts", "file", 20); + const otherEditor = makeEditor("/repo/src/b.ts", "file", 20); + + (vscode.window.visibleTextEditors as any[]).push(activeEditor, otherEditor); + (vscode.window as any).activeTextEditor = activeEditor; + + const provider = new BookmarkDecorationProvider( + store as any, + { extensionUri } as any, + ); + provider.setRepoRoot("/repo"); + + await provider.refreshActiveEditor(); + + expect(activeEditor.setDecorations).toHaveBeenCalledTimes(1); + expect(otherEditor.setDecorations).not.toHaveBeenCalled(); + }); + + it("clearAllEditors clears all applied decoration types from visible editors", async () => { + const deco1 = makeDecoration(1); + const deco2 = makeDecoration(2); + + vi.mocked(vscode.window.createTextEditorDecorationType) + .mockReturnValueOnce(deco1 as any) + .mockReturnValueOnce(deco2 as any); + + const store = makeStore([ + { + slot: 1, + target: { + repoRelativePath: "src/a.ts", + line: 1, + column: 0, + }, + }, + { + slot: 2, + target: { + repoRelativePath: "src/a.ts", + line: 4, + column: 0, + }, + }, + ]); + + const editor = makeEditor("/repo/src/a.ts", "file", 20); + (vscode.window.visibleTextEditors as any[]).push(editor); + + const provider = new BookmarkDecorationProvider( + store as any, + { extensionUri } as any, + ); + provider.setRepoRoot("/repo"); + + await provider.refreshVisibleEditors(); + editor.setDecorations.mockClear(); + + provider.clearAllEditors(); + + expect(editor.setDecorations).toHaveBeenCalledTimes(2); + expect(editor.setDecorations).toHaveBeenNthCalledWith(1, deco1, []); + expect(editor.setDecorations).toHaveBeenNthCalledWith(2, deco2, []); + }); + + it("dispose disposes created decoration types", async () => { + const deco1 = makeDecoration(1); + const deco2 = makeDecoration(2); + + vi.mocked(vscode.window.createTextEditorDecorationType) + .mockReturnValueOnce(deco1 as any) + .mockReturnValueOnce(deco2 as any); + + const store = makeStore([ + { + slot: 1, + target: { + repoRelativePath: "src/a.ts", + line: 1, + column: 0, + }, + }, + { + slot: 2, + target: { + repoRelativePath: "src/a.ts", + line: 4, + column: 0, + }, + }, + ]); + + const editor = makeEditor("/repo/src/a.ts", "file", 20); + (vscode.window.visibleTextEditors as any[]).push(editor); + + const provider = new BookmarkDecorationProvider( + store as any, + { extensionUri } as any, + ); + provider.setRepoRoot("/repo"); + + await provider.refreshVisibleEditors(); + provider.dispose(); + + expect(deco1.dispose).toHaveBeenCalledTimes(1); + expect(deco2.dispose).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/src/usecases/bookmark/clearAllBookmarks.ts b/src/usecases/bookmark/clearAllBookmarks.ts new file mode 100644 index 0000000..517814d --- /dev/null +++ b/src/usecases/bookmark/clearAllBookmarks.ts @@ -0,0 +1,29 @@ +import type { BookmarkRepository } from "../../core/bookmark/bookmarkRepository"; + +export interface ClearAllBookmarksPrompt { + confirmClearAllBookmarks(count: number): Promise; + showInfo(message: string): Promise; +} + +export class ClearAllBookmarks { + constructor( + private readonly bookmarks: BookmarkRepository, + private readonly prompt: ClearAllBookmarksPrompt, + ) {} + + async run(repoRoot: string): Promise { + const all = await this.bookmarks.getAll(repoRoot); + if (all.length === 0) { + await this.prompt.showInfo("No bookmarks to clear"); + return; + } + + const approved = await this.prompt.confirmClearAllBookmarks(all.length); + if (!approved) { + return; + } + + await this.bookmarks.clearAll(repoRoot); + await this.prompt.showInfo(`Cleared ${all.length} bookmark(s)`); + } +} \ No newline at end of file diff --git a/src/usecases/bookmark/clearBookmark.ts b/src/usecases/bookmark/clearBookmark.ts new file mode 100644 index 0000000..f15f51e --- /dev/null +++ b/src/usecases/bookmark/clearBookmark.ts @@ -0,0 +1,24 @@ +import type { BookmarkSlot } from "../../core/bookmark/bookmark"; +import type { BookmarkRepository } from "../../core/bookmark/bookmarkRepository"; + +export interface ClearBookmarkPrompt { + pickBookmarkSlot(): Promise; + showInfo(message: string): Promise; +} + +export class ClearBookmark { + constructor( + private readonly bookmarks: BookmarkRepository, + private readonly prompt: ClearBookmarkPrompt, + ) {} + + async run(repoRoot: string, slot?: BookmarkSlot): Promise { + const resolvedSlot = slot ?? (await this.prompt.pickBookmarkSlot()); + if (!resolvedSlot) { + return; + } + + await this.bookmarks.clear(repoRoot, resolvedSlot); + await this.prompt.showInfo(`Bookmark ${resolvedSlot} cleared`); + } +} \ No newline at end of file diff --git a/src/usecases/bookmark/jumpToBookmark.ts b/src/usecases/bookmark/jumpToBookmark.ts new file mode 100644 index 0000000..6cce2cd --- /dev/null +++ b/src/usecases/bookmark/jumpToBookmark.ts @@ -0,0 +1,39 @@ +import * as fs from "fs/promises"; +import * as path from "path"; +import type { BookmarkSlot } from "../../core/bookmark/bookmark"; +import type { BookmarkRepository } from "../../core/bookmark/bookmarkRepository"; +import type { VsCodeBookmarkEditor } from "../../adapters/vscode/bookmarkEditor"; + +export interface JumpBookmarkPrompt { + showInfo(message: string): Promise; + showWarning(message: string): Promise; +} + +export class JumpToBookmark { + constructor( + private readonly bookmarks: BookmarkRepository, + private readonly editor: VsCodeBookmarkEditor, + private readonly prompt: JumpBookmarkPrompt, + ) {} + + async run(repoRoot: string, slot: BookmarkSlot): Promise { + const entry = await this.bookmarks.getBySlot(repoRoot, slot); + if (!entry) { + await this.prompt.showInfo(`Bookmark ${slot} is empty`); + return; + } + + const absolutePath = path.join(repoRoot, entry.target.repoRelativePath); + + try { + await fs.stat(absolutePath); + } catch { + await this.prompt.showWarning( + `Bookmark ${slot} points to a missing file: ${entry.target.repoRelativePath}`, + ); + return; + } + + await this.editor.openTarget(repoRoot, entry.target); + } +} diff --git a/src/usecases/bookmark/setBookmark.ts b/src/usecases/bookmark/setBookmark.ts new file mode 100644 index 0000000..5de0fe7 --- /dev/null +++ b/src/usecases/bookmark/setBookmark.ts @@ -0,0 +1,73 @@ +import type { + BookmarkEntry, + BookmarkSlot, + BookmarkTarget, + } from "../../core/bookmark/bookmark"; + import { + formatBookmarkTarget, + isSameBookmarkTarget, + } from "../../core/bookmark/bookmark"; + import type { BookmarkRepository } from "../../core/bookmark/bookmarkRepository"; + + export interface BookmarkPrompt { + pickBookmarkSlot(): Promise; + confirmBookmarkOverwrite( + existing: BookmarkEntry, + incoming: BookmarkEntry, + ): Promise; + showInfo(message: string): Promise; + showWarning(message: string): Promise; + } + + export class SetBookmark { + constructor( + private readonly bookmarks: BookmarkRepository, + private readonly prompt: BookmarkPrompt, + ) {} + + async run(params: { + repoRoot: string; + target: BookmarkTarget; + slot?: BookmarkSlot; + }): Promise { + const slot = params.slot ?? (await this.prompt.pickBookmarkSlot()); + if (!slot) { + return; + } + + const incoming: BookmarkEntry = { + slot, + target: params.target, + }; + + const existing = await this.bookmarks.getBySlot(params.repoRoot, slot); + + if (!existing) { + await this.bookmarks.set(params.repoRoot, incoming); + await this.prompt.showInfo( + `Bookmark ${slot} set to ${formatBookmarkTarget(incoming.target)}`, + ); + return; + } + + if (isSameBookmarkTarget(existing.target, incoming.target)) { + await this.prompt.showInfo( + `Bookmark ${slot} already points to ${formatBookmarkTarget(incoming.target)}`, + ); + return; + } + + const approved = await this.prompt.confirmBookmarkOverwrite( + existing, + incoming, + ); + if (!approved) { + return; + } + + await this.bookmarks.set(params.repoRoot, incoming); + await this.prompt.showInfo( + `Bookmark ${slot} updated to ${formatBookmarkTarget(incoming.target)}`, + ); + } + } \ No newline at end of file diff --git a/src/views/bookmark/bookmarkDecorationProvider.ts b/src/views/bookmark/bookmarkDecorationProvider.ts new file mode 100644 index 0000000..65440e4 --- /dev/null +++ b/src/views/bookmark/bookmarkDecorationProvider.ts @@ -0,0 +1,147 @@ +import * as path from "path"; +import * as vscode from "vscode"; +import type { BookmarkEntry, BookmarkSlot } from "../../core/bookmark/bookmark"; +import type { WorkspaceStateStore } from "../../adapters/storage/workspaceStateStore"; +import { normalizeRepoRelPath } from "../../utils/paths"; + +export class BookmarkDecorationProvider implements vscode.Disposable { + private repoRoot = ""; + private readonly decorations = new Map< + BookmarkSlot, + vscode.TextEditorDecorationType + >(); + + constructor( + private readonly store: WorkspaceStateStore, + private readonly context: vscode.ExtensionContext, + ) {} + + setRepoRoot(repoRoot: string): void { + this.repoRoot = repoRoot; + } + + async refreshVisibleEditors(): Promise { + await Promise.all( + vscode.window.visibleTextEditors.map((editor) => + this.refreshEditor(editor), + ), + ); + } + + async refreshActiveEditor(): Promise { + const editor = vscode.window.activeTextEditor; + if (!editor) { + return; + } + + await this.refreshEditor(editor); + } + + clearAllEditors(): void { + for (const editor of vscode.window.visibleTextEditors) { + for (const deco of this.decorations.values()) { + editor.setDecorations(deco, []); + } + } + } + + dispose(): void { + for (const deco of this.decorations.values()) { + deco.dispose(); + } + this.decorations.clear(); + } + + private async refreshEditor(editor: vscode.TextEditor): Promise { + for (const deco of this.decorations.values()) { + editor.setDecorations(deco, []); + } + + if (!this.repoRoot) { + return; + } + + if (editor.document.uri.scheme !== "file") { + return; + } + + const filePath = path.resolve(editor.document.uri.fsPath); + const repoRoot = path.resolve(this.repoRoot); + + const rel = path.relative(repoRoot, filePath); + if (rel.startsWith("..") || path.isAbsolute(rel)) { + return; + } + + const repoRelativePath = normalizeRepoRelPath(rel); + const bookmarks = await this.store.getAll(this.repoRoot); + + const matching = bookmarks.filter( + (entry) => + normalizeRepoRelPath(entry.target.repoRelativePath) === repoRelativePath, + ); + + if (matching.length === 0) { + return; + } + + for (const entry of matching) { + const line = this.clampLine(editor.document, entry.target.line); + const range = new vscode.Range(line, 0, line, 0); + const decoration = this.getDecoration(entry.slot); + + editor.setDecorations(decoration, [ + { + range, + hoverMessage: `Bookmark ${entry.slot}`, + }, + ]); + } + } + + private clampLine(document: vscode.TextDocument, line: number): number { + if (document.lineCount <= 0) { + return 0; + } + + if (line < 0) { + return 0; + } + + if (line >= document.lineCount) { + return document.lineCount - 1; + } + + return line; + } + + private getDecoration(slot: BookmarkSlot): vscode.TextEditorDecorationType { + const existing = this.decorations.get(slot); + if (existing) { + return existing; + } + + const iconFile = vscode.Uri.joinPath( + this.context.extensionUri, + "media", + "bookmarks", + `bookmark-${slot}.svg`, + ); + + const created = vscode.window.createTextEditorDecorationType({ + isWholeLine: true, + gutterIconPath: iconFile, + gutterIconSize: "contain", + overviewRulerLane: vscode.OverviewRulerLane.Left, + light: { + backgroundColor: "rgba(215, 186, 125, 0.08)", + }, + dark: { + backgroundColor: "rgba(215, 186, 125, 0.08)", + }, + }); + + this.decorations.set(slot, created); + return created; + } +} \ No newline at end of file diff --git a/src/views/worklistDecorationProvider.ts b/src/views/worklistDecorationProvider.ts index e4fd02b..e083dd9 100644 --- a/src/views/worklistDecorationProvider.ts +++ b/src/views/worklistDecorationProvider.ts @@ -48,13 +48,14 @@ export class WorklistDecorationProvider return; } - const stageState = this.fileStageStates.get(rel) ?? "none"; + const normalizedRel = normalizeRepoRelPath(rel); + const stageState = this.fileStageStates.get(normalizedRel) ?? "none"; // Priority: Unversioned > Default > Custom const unversioned = state.lists.find( (l) => l.id === SystemChangelist.Unversioned, ); - if (unversioned?.files.includes(rel)) { + if (unversioned?.files.includes(normalizedRel)) { return new vscode.FileDecoration( "U", "Unversioned", @@ -65,7 +66,7 @@ export class WorklistDecorationProvider const defaultList = state.lists.find( (l) => l.id === SystemChangelist.Default, ); - if (defaultList?.files.includes(rel)) { + if (defaultList?.files.includes(normalizedRel)) { return decorationForList("D", "Default", stageState); } @@ -73,7 +74,7 @@ export class WorklistDecorationProvider (l) => l.id !== SystemChangelist.Unversioned && l.id !== SystemChangelist.Default && - l.files.includes(rel), + l.files.includes(normalizedRel), ); if (customList) {