Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions frontend/app/element/markdown-anchor.test.ts
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");
});
});
22 changes: 22 additions & 0 deletions frontend/app/element/markdown-anchor.ts
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 };
417 changes: 233 additions & 184 deletions frontend/app/element/markdown.tsx

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions frontend/app/modals/modalregistry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { MessageModal } from "@/app/modals/messagemodal";
import { NewInstallOnboardingModal } from "@/app/onboarding/onboarding";
import { UpgradeOnboardingModal } from "@/app/onboarding/onboarding-upgrade";
import { UpgradeOnboardingPatch } from "@/app/onboarding/onboarding-upgrade-patch";
import { EditBookmarksModal } from "@/app/view/preview/bookmarks-edit-modal";
import { DeleteFileModal, PublishAppModal, RenameFileModal } from "@/builder/builder-apppanel";
import { SetSecretDialog } from "@/builder/tabs/builder-secrettab";
import { AboutModal } from "./about";
Expand All @@ -21,6 +22,7 @@ const modalRegistry: { [key: string]: React.ComponentType<any> } = {
[RenameFileModal.displayName || "RenameFileModal"]: RenameFileModal,
[DeleteFileModal.displayName || "DeleteFileModal"]: DeleteFileModal,
[SetSecretDialog.displayName || "SetSecretDialog"]: SetSecretDialog,
[EditBookmarksModal.displayName || "EditBookmarksModal"]: EditBookmarksModal,
};

export const getModalComponent = (key: string): React.ComponentType<any> | undefined => {
Expand Down
28 changes: 28 additions & 0 deletions frontend/app/store/bookmarksmodel.test.ts
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);
});
});
100 changes: 100 additions & 0 deletions frontend/app/store/bookmarksmodel.ts
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 },
});
}
Comment on lines +79 to +95

Copy link
Copy Markdown
Contributor

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 SetFileBookmarkCommand calls.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/app/store/bookmarksmodel.ts` around lines 79 - 95, Update reorder in
the bookmark model to persist the entire ordering through a single transactional
or bulk reorder RPC instead of issuing one SetFileBookmarkCommand per bookmark.
Preserve the orderedKeys mapping and display:order values, and ensure a failed
operation leaves all bookmark orders unchanged.

}
}

export { BookmarksModel, fileBookmarksAtom, nextDisplayOrder, sortBookmarks };
export type { BookmarkEntry };
12 changes: 12 additions & 0 deletions frontend/app/store/wshclientapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,12 @@ export class RpcApiType {
return client.wshRpcCall("deletebuilder", data, opts);
}

// command "deletefilebookmark" [call]
DeleteFileBookmarkCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> {
if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "deletefilebookmark", data, opts);
return client.wshRpcCall("deletefilebookmark", data, opts);
}

// command "deletesubblock" [call]
DeleteSubBlockCommand(client: WshClient, data: CommandDeleteBlockData, opts?: RpcOpts): Promise<void> {
if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "deletesubblock", data, opts);
Expand Down Expand Up @@ -852,6 +858,12 @@ export class RpcApiType {
return client.wshRpcCall("setconnectionsconfig", data, opts);
}

// command "setfilebookmark" [call]
SetFileBookmarkCommand(client: WshClient, data: FileBookmarkSetRequest, opts?: RpcOpts): Promise<void> {
if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "setfilebookmark", data, opts);
return client.wshRpcCall("setfilebookmark", data, opts);
}

// command "setmeta" [call]
SetMetaCommand(client: WshClient, data: CommandSetMetaData, opts?: RpcOpts): Promise<void> {
if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "setmeta", data, opts);
Expand Down
63 changes: 63 additions & 0 deletions frontend/app/view/preview/bookmarks-edit-modal.tsx
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 };
25 changes: 25 additions & 0 deletions frontend/app/view/preview/bookmarks-menu.test.ts
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);
});
});
43 changes: 43 additions & 0 deletions frontend/app/view/preview/bookmarks-menu.ts
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 };
16 changes: 16 additions & 0 deletions frontend/app/view/preview/preview-directory.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

import { BookmarksModel } from "@/app/store/bookmarksmodel";
import { ContextMenuModel } from "@/app/store/contextmenu";
import { globalStore } from "@/app/store/jotaiStore";
import { TabRpcClient } from "@/app/store/wshrpcutil";
Expand Down Expand Up @@ -407,6 +408,21 @@ function TableBody({
click: () => fireAndForget(() => navigator.clipboard.writeText(shellQuote([finfo.path]))),
},
];
menu.push(
{ type: "separator" },
{
label: "Add to Bookmarks",
click: () =>
fireAndForget(() =>
BookmarksModel.getInstance().add({
bookmarktype: finfo.isdir ? "folder" : "file",
label: fileName,
path: finfo.path,
connection: conn ?? "",
} as FileBookmark)
),
}
);
addOpenMenuItems(menu, conn, finfo);
menu.push(
{
Expand Down
Loading