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.
+
+
+
+---
+
## 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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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) {