-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
File explorer bookmarks (folder / file / document-position) #3443
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
dandacompany
wants to merge
12
commits into
wavetermdev:main
Choose a base branch
from
dandacompany:file-explorer-bookmarks
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
0bc3f31
Preserve markdown preview scroll position across refresh
dandacompany 6d9a9ee
feat(bookmarks): add FileBookmark config type, helpers, and defaults
dandacompany a2221e4
feat(bookmarks): add Set/Delete FileBookmark RPC commands
dandacompany 4c388c1
feat(bookmarks): add BookmarksModel and fileBookmarksAtom
dandacompany 16dd3ec
feat(bookmarks): expose Markdown anchor capture/restore via ref
dandacompany e2784d2
fix(bookmarks): restore dropped cn import in markdown.tsx
dandacompany e1c899d
feat(bookmarks): wire PreviewModel open/add + star dropdown
dandacompany e538d7d
feat(bookmarks): capture and restore document position in viewers
dandacompany 13913fb
feat(bookmarks): add 'Add to Bookmarks' to directory context menu
dandacompany fddc73e
feat(bookmarks): add edit bookmarks modal (rename/reorder/delete)
dandacompany ba57763
fix(bookmarks): show star on code views and fix markdown cross-file d…
dandacompany 6612ad8
fix(bookmarks): add filebookmarks to mock default config
dandacompany File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| // Copyright 2026, Command Line Inc. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import { describe, expect, it } from "vitest"; | ||
| import { pickCurrentAnchor } from "./markdown-anchor"; | ||
|
|
||
| describe("pickCurrentAnchor", () => { | ||
| it("returns null for empty", () => { | ||
| expect(pickCurrentAnchor([], 100)).toBeNull(); | ||
| }); | ||
| it("returns first when all below viewport", () => { | ||
| const items = [ | ||
| { href: "#a", top: 50 }, | ||
| { href: "#b", top: 120 }, | ||
| ]; | ||
| expect(pickCurrentAnchor(items, 0)).toBe("#a"); | ||
| }); | ||
| it("returns the last heading at/above viewport top", () => { | ||
| const items = [ | ||
| { href: "#a", top: -30 }, | ||
| { href: "#b", top: 10 }, | ||
| { href: "#c", top: 200 }, | ||
| ]; | ||
| expect(pickCurrentAnchor(items, 12)).toBe("#b"); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| // Copyright 2026, Command Line Inc. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| type AnchorItem = { href: string; top: number }; | ||
|
|
||
| // 뷰포트 상단(0 기준 상대좌표)에 걸쳐 있거나 바로 위에 있는 마지막 heading을 고른다. | ||
| function pickCurrentAnchor(items: AnchorItem[], viewportTop: number): string | null { | ||
| if (items == null || items.length == 0) { | ||
| return null; | ||
| } | ||
| const threshold = viewportTop + 4; | ||
| let current: string | null = null; | ||
| for (const item of items) { | ||
| if (item.top <= threshold) { | ||
| current = item.href; | ||
| } | ||
| } | ||
| return current ?? items[0].href; | ||
| } | ||
|
|
||
| export { pickCurrentAnchor }; | ||
| export type { AnchorItem }; |
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| // Copyright 2026, Command Line Inc. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import { describe, expect, it } from "vitest"; | ||
| import { nextDisplayOrder, sortBookmarks } from "./bookmarksmodel"; | ||
|
|
||
| describe("sortBookmarks", () => { | ||
| it("returns [] for null", () => { | ||
| expect(sortBookmarks(null)).toEqual([]); | ||
| }); | ||
| it("sorts by display:order ascending", () => { | ||
| const map = { | ||
| b: { bookmarktype: "folder", label: "B", path: "/b", "display:order": 2 } as any, | ||
| a: { bookmarktype: "folder", label: "A", path: "/a", "display:order": 1 } as any, | ||
| }; | ||
| const out = sortBookmarks(map); | ||
| expect(out.map((e) => e.key)).toEqual(["a", "b"]); | ||
| }); | ||
| }); | ||
|
|
||
| describe("nextDisplayOrder", () => { | ||
| it("returns 1 for empty", () => { | ||
| expect(nextDisplayOrder([])).toBe(1); | ||
| }); | ||
| it("returns max+1", () => { | ||
| expect(nextDisplayOrder([{ key: "x", "display:order": 4 } as any])).toBe(5); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| // Copyright 2026, Command Line Inc. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import { atoms } from "@/app/store/global-atoms"; | ||
| import { globalStore } from "@/app/store/jotaiStore"; | ||
| import { RpcApi } from "@/app/store/wshclientapi"; | ||
| import { TabRpcClient } from "@/app/store/wshrpcutil"; | ||
| import { Atom, atom } from "jotai"; | ||
|
|
||
| type BookmarkEntry = FileBookmark & { key: string }; | ||
|
|
||
| function sortBookmarks(map: { [key: string]: FileBookmark }): BookmarkEntry[] { | ||
| if (map == null) { | ||
| return []; | ||
| } | ||
| const entries: BookmarkEntry[] = Object.keys(map).map((key) => ({ key, ...map[key] })); | ||
| entries.sort((a, b) => (a["display:order"] ?? 0) - (b["display:order"] ?? 0)); | ||
| return entries; | ||
| } | ||
|
|
||
| function nextDisplayOrder(entries: BookmarkEntry[]): number { | ||
| let max = 0; | ||
| for (const e of entries) { | ||
| const ord = e["display:order"] ?? 0; | ||
| if (ord > max) { | ||
| max = ord; | ||
| } | ||
| } | ||
| return max + 1; | ||
| } | ||
|
|
||
| const fileBookmarksAtom: Atom<BookmarkEntry[]> = atom((get) => { | ||
| const fullConfig = get(atoms.fullConfigAtom); | ||
| return sortBookmarks(fullConfig?.filebookmarks); | ||
| }); | ||
|
|
||
| class BookmarksModel { | ||
| private static instance: BookmarksModel | null = null; | ||
|
|
||
| static getInstance(): BookmarksModel { | ||
| if (!BookmarksModel.instance) { | ||
| BookmarksModel.instance = new BookmarksModel(); | ||
| } | ||
| return BookmarksModel.instance; | ||
| } | ||
|
|
||
| private constructor() {} | ||
|
|
||
| getBookmarks(): BookmarkEntry[] { | ||
| return globalStore.get(fileBookmarksAtom); | ||
| } | ||
|
|
||
| async add(bookmark: FileBookmark): Promise<string> { | ||
| const key = crypto.randomUUID(); | ||
| const order = nextDisplayOrder(this.getBookmarks()); | ||
| await RpcApi.SetFileBookmarkCommand(TabRpcClient, { | ||
| key, | ||
| bookmark: { ...bookmark, "display:order": order }, | ||
| }); | ||
| return key; | ||
| } | ||
|
|
||
| async update(key: string, patch: Partial<FileBookmark>): Promise<void> { | ||
| const existing = this.getBookmarks().find((b) => b.key == key); | ||
| if (existing == null) { | ||
| return; | ||
| } | ||
| const { key: _omit, ...rest } = existing; | ||
| await RpcApi.SetFileBookmarkCommand(TabRpcClient, { | ||
| key, | ||
| bookmark: { ...rest, ...patch }, | ||
| }); | ||
| } | ||
|
|
||
| async remove(key: string): Promise<void> { | ||
| await RpcApi.DeleteFileBookmarkCommand(TabRpcClient, key); | ||
| } | ||
|
|
||
| async reorder(orderedKeys: string[]): Promise<void> { | ||
| const byKey = new Map(this.getBookmarks().map((b) => [b.key, b])); | ||
| for (let i = 0; i < orderedKeys.length; i++) { | ||
| const b = byKey.get(orderedKeys[i]); | ||
| if (b == null) { | ||
| continue; | ||
| } | ||
| const newOrder = i + 1; | ||
| if ((b["display:order"] ?? 0) == newOrder) { | ||
| continue; | ||
| } | ||
| const { key: _omit, ...rest } = b; | ||
| await RpcApi.SetFileBookmarkCommand(TabRpcClient, { | ||
| key: b.key, | ||
| bookmark: { ...rest, "display:order": newOrder }, | ||
| }); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| export { BookmarksModel, fileBookmarksAtom, nextDisplayOrder, sortBookmarks }; | ||
| export type { BookmarkEntry }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| // Copyright 2026, Command Line Inc. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import { Modal } from "@/app/modals/modal"; | ||
| import { BookmarksModel, fileBookmarksAtom, type BookmarkEntry } from "@/app/store/bookmarksmodel"; | ||
| import { modalsModel } from "@/app/store/modalmodel"; | ||
| import { fireAndForget } from "@/util/util"; | ||
| import { useAtomValue } from "jotai"; | ||
|
|
||
| function EditBookmarksModal() { | ||
| const entries = useAtomValue(fileBookmarksAtom); | ||
| const model = BookmarksModel.getInstance(); | ||
|
|
||
| const move = (index: number, delta: number) => { | ||
| const keys = entries.map((e) => e.key); | ||
| const target = index + delta; | ||
| if (target < 0 || target >= keys.length) { | ||
| return; | ||
| } | ||
| [keys[index], keys[target]] = [keys[target], keys[index]]; | ||
| fireAndForget(() => model.reorder(keys)); | ||
| }; | ||
|
|
||
| const rename = (bm: BookmarkEntry, label: string) => { | ||
| fireAndForget(() => model.update(bm.key, { label })); | ||
| }; | ||
|
|
||
| return ( | ||
| <Modal onClose={() => modalsModel.popModal()}> | ||
| <div className="flex flex-col gap-2 p-4 min-w-[420px]"> | ||
| <div className="text-lg font-bold">Edit Bookmarks</div> | ||
| {entries.length == 0 && <div className="text-secondary">No bookmarks yet.</div>} | ||
| {entries.map((bm, i) => ( | ||
| <div key={bm.key} className="flex flex-row items-center gap-2"> | ||
| <input | ||
| className="flex-1 bg-transparent border border-border rounded px-2 py-1" | ||
| defaultValue={bm.label} | ||
| onBlur={(e) => rename(bm, e.target.value)} | ||
| /> | ||
| <span className="text-secondary text-xs truncate max-w-[140px]">{bm.path}</span> | ||
| <button className="cursor-pointer px-1" onClick={() => move(i, -1)} title="Move up"> | ||
| ↑ | ||
| </button> | ||
| <button className="cursor-pointer px-1" onClick={() => move(i, 1)} title="Move down"> | ||
| ↓ | ||
| </button> | ||
| <button | ||
| className="cursor-pointer px-1 text-error" | ||
| onClick={() => fireAndForget(() => model.remove(bm.key))} | ||
| title="Delete" | ||
| > | ||
| ✕ | ||
| </button> | ||
| </div> | ||
| ))} | ||
| </div> | ||
| </Modal> | ||
| ); | ||
| } | ||
|
|
||
| EditBookmarksModal.displayName = "EditBookmarksModal"; | ||
|
|
||
| export { EditBookmarksModal }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| // Copyright 2026, Command Line Inc. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import { describe, expect, it, vi } from "vitest"; | ||
| import { bookmarkIcon, bookmarkLabel, buildBookmarkMenu } from "./bookmarks-menu"; | ||
|
|
||
| const folder = { key: "k", bookmarktype: "folder", label: "Home", path: "~" } as any; | ||
|
|
||
| describe("bookmark menu helpers", () => { | ||
| it("icon by type", () => { | ||
| expect(bookmarkIcon(folder)).toBe("folder"); | ||
| expect(bookmarkIcon({ ...folder, bookmarktype: "docpos" })).toBe("bookmark"); | ||
| expect(bookmarkIcon({ ...folder, bookmarktype: "file" })).toBe("file"); | ||
| }); | ||
| it("label falls back to path", () => { | ||
| expect(bookmarkLabel({ ...folder, label: "" })).toBe("~"); | ||
| }); | ||
| it("builds menu with entries + actions", () => { | ||
| const onOpen = vi.fn(); | ||
| const menu = buildBookmarkMenu([folder], { onOpen, onAddCurrent: vi.fn(), onEdit: vi.fn() }); | ||
| expect(menu.length).toBe(4); // 1 bookmark + separator + add + edit | ||
| (menu[0] as any).click(); | ||
| expect(onOpen).toHaveBeenCalledWith(folder); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| // Copyright 2026, Command Line Inc. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import type { BookmarkEntry } from "@/app/store/bookmarksmodel"; | ||
|
|
||
| function bookmarkIcon(bm: BookmarkEntry): string { | ||
| if (bm.bookmarktype == "folder") { | ||
| return "folder"; | ||
| } | ||
| if (bm.bookmarktype == "docpos") { | ||
| return "bookmark"; | ||
| } | ||
| return "file"; | ||
| } | ||
|
|
||
| function bookmarkLabel(bm: BookmarkEntry): string { | ||
| if (bm.label) { | ||
| return bm.label; | ||
| } | ||
| return bm.path; | ||
| } | ||
|
|
||
| type BookmarkMenuHandlers = { | ||
| onOpen: (bm: BookmarkEntry) => void; | ||
| onAddCurrent: () => void; | ||
| onEdit: () => void; | ||
| }; | ||
|
|
||
| function buildBookmarkMenu(entries: BookmarkEntry[], handlers: BookmarkMenuHandlers): ContextMenuItem[] { | ||
| const menu: ContextMenuItem[] = entries.map((bm) => ({ | ||
| label: bookmarkLabel(bm), | ||
| click: () => handlers.onOpen(bm), | ||
| })); | ||
| if (entries.length > 0) { | ||
| menu.push({ type: "separator" }); | ||
| } | ||
| menu.push({ label: "Add Current Location", click: () => handlers.onAddCurrent() }); | ||
| menu.push({ label: "Edit Bookmarks…", click: () => handlers.onEdit() }); | ||
| return menu; | ||
| } | ||
|
|
||
| export { bookmarkIcon, bookmarkLabel, buildBookmarkMenu }; | ||
| export type { BookmarkMenuHandlers }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Make reorder persistence atomic.
This loop issues one full bookmark update per item. If a later RPC fails, earlier order changes remain persisted, leaving a partially reordered list. Use a transactional/bulk reorder operation rather than independent
SetFileBookmarkCommandcalls.🤖 Prompt for AI Agents