feat: right-click menus everywhere, workspace/session cost, long-term DB safety
Right-click menus: one portal shell (components/ContextMenu.tsx) for every item with actions - sidebar/Recents/archived session rows (SessionMenu), console tabs, pane headers, the Changes panel's files/tree/commits, the workspace home, sessions outside a workspace (SessionRowMenu: Sessions table, Dashboard, Activity, Workflow runs), prompt images, every Settings list and every Claude Code/Codex config list (FileItemMenu). Buttons stay; a delete keeps its button's confirm; the shell stops portal clicks from reaching the card that opened it. Status bar: Workspace $ and Session $ on the Workspace page, from ?lane= on GET /api/pricing/cost and the focused/replayed session's cost. Database, no data dropped: synchronous=NORMAL, journal_size_limit 64 MB, hourly PASSIVE checkpoint + PRAGMA optimize, idx_events_session_created (replacing the redundant idx_events_session), watchdog reads sessions.transcript_path, transcript parsed before the hook's write transaction, GET /api/sessions/:id?events=0 for the web page (reloads only on its own session, debounced), streamed export, transactional clear-data and chunked cleanup, subagent import reads tool_use_ids once, runtime logs rotated past 5 MB.
This commit is contained in:
@@ -63,6 +63,7 @@ import { api, gitTarget } from "../../lib/api";
|
||||
import { pasteToLane } from "../../lib/panePaste";
|
||||
import { LaneDiffView, LaneFileView, type DiffLayout, type LaneDiffChange } from "./LaneDiffView";
|
||||
import { LaneConflictView } from "./LaneConflictView";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
|
||||
import type {
|
||||
@@ -151,6 +152,7 @@ function TreeNodes({
|
||||
expanded,
|
||||
onToggleDir,
|
||||
onOpenFile,
|
||||
onMenu,
|
||||
active,
|
||||
}: {
|
||||
path: string;
|
||||
@@ -159,6 +161,8 @@ function TreeNodes({
|
||||
expanded: Set<string>;
|
||||
onToggleDir: (path: string) => void;
|
||||
onOpenFile: (path: string) => void;
|
||||
/** Right-click on an entry. */
|
||||
onMenu?: (e: React.MouseEvent, entry: LaneTreeEntry) => void;
|
||||
/** The file open beside the tree, drawn selected. */
|
||||
active?: string | null;
|
||||
}) {
|
||||
@@ -174,6 +178,7 @@ function TreeNodes({
|
||||
type="button"
|
||||
data-tree-status={mark?.letter ?? (e.changed ? "dir" : undefined)}
|
||||
onClick={() => (e.dir ? onToggleDir(e.path) : onOpenFile(e.path))}
|
||||
onContextMenu={onMenu && ((ev) => onMenu(ev, e))}
|
||||
style={{ paddingLeft: `${depth * 0.9 + 0.25}rem` }}
|
||||
aria-current={active === e.path ? "true" : undefined}
|
||||
className={`flex w-full items-center gap-1 rounded px-1 py-0.5 text-left font-mono text-[11px] hover:bg-surface-2 ${
|
||||
@@ -217,6 +222,7 @@ function TreeNodes({
|
||||
expanded={expanded}
|
||||
onToggleDir={onToggleDir}
|
||||
onOpenFile={onOpenFile}
|
||||
onMenu={onMenu}
|
||||
active={active}
|
||||
/>
|
||||
</ul>
|
||||
@@ -372,10 +378,12 @@ function ChangeRow({
|
||||
onAct,
|
||||
onArm,
|
||||
onResolve,
|
||||
onMenu,
|
||||
}: {
|
||||
f: LaneChangeFile;
|
||||
range: LaneChangeRange;
|
||||
onResolve: Resolve;
|
||||
onMenu: (e: React.MouseEvent, f: LaneChangeFile) => void;
|
||||
busy: boolean;
|
||||
/** This row's discard has been clicked once and awaits the second click. */
|
||||
armed: boolean;
|
||||
@@ -385,7 +393,7 @@ function ChangeRow({
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
return (
|
||||
<li className="group flex items-baseline gap-1">
|
||||
<li className="group flex items-baseline gap-1" onContextMenu={(e) => onMenu(e, f)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(f.path)}
|
||||
@@ -844,6 +852,136 @@ export function LaneChangesPanel({
|
||||
};
|
||||
const resolve: Resolve = (path, side) => void mergeAct("resolve", { path, side });
|
||||
|
||||
const fileCtx = useContextMenu<LaneChangeFile>();
|
||||
const treeCtx = useContextMenu<LaneTreeEntry>();
|
||||
const commitCtx = useContextMenu<LaneCommit>();
|
||||
const viewCommit = (sha: string) => {
|
||||
setSha(sha);
|
||||
setRange("commit");
|
||||
setMode("review");
|
||||
};
|
||||
const copy = (text: string) => void navigator.clipboard.writeText(text);
|
||||
// A menu item: close its menu, then act.
|
||||
const pick = (close: () => void, fn: () => void) => () => {
|
||||
close();
|
||||
fn();
|
||||
};
|
||||
const menuFile = fileCtx.menu?.item;
|
||||
const menuArmed = !!menuFile && confirmDiscard === menuFile.path;
|
||||
const menus = (
|
||||
<>
|
||||
{fileCtx.menu && menuFile && (
|
||||
<ContextMenu
|
||||
x={fileCtx.menu.x}
|
||||
y={fileCtx.menu.y}
|
||||
onClose={fileCtx.close}
|
||||
testId="lane-changes-file-menu"
|
||||
>
|
||||
<MenuItem onSelect={pick(fileCtx.close, () => show(menuFile.path))}>
|
||||
{t("changes.openFile", { path: menuFile.path.split("/").pop() })}
|
||||
</MenuItem>
|
||||
{range === "work" && menuFile.conflicted && (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
disabled={busy}
|
||||
onSelect={pick(fileCtx.close, () => resolve(menuFile.path, "ours"))}
|
||||
>
|
||||
{t("changes.takeOurs")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
disabled={busy}
|
||||
onSelect={pick(fileCtx.close, () => resolve(menuFile.path, "theirs"))}
|
||||
>
|
||||
{t("changes.takeTheirs")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
disabled={busy}
|
||||
onSelect={pick(fileCtx.close, () => resolve(menuFile.path, "done"))}
|
||||
>
|
||||
{t("changes.markResolved")}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
{range === "work" && !menuFile.conflicted && (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
{(menuFile.unstaged || menuFile.untracked) && (
|
||||
<MenuItem
|
||||
disabled={busy}
|
||||
onSelect={pick(fileCtx.close, () => void act("stage", menuFile.path))}
|
||||
>
|
||||
{t("changes.stage")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{menuFile.staged && (
|
||||
<MenuItem
|
||||
disabled={busy}
|
||||
onSelect={pick(fileCtx.close, () => void act("unstage", menuFile.path))}
|
||||
>
|
||||
{t("changes.unstage")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
danger
|
||||
disabled={busy}
|
||||
testId="lane-changes-menu-discard"
|
||||
// Two clicks here too: the first arms (the row's trash
|
||||
// shows it), only the second reaches the server.
|
||||
onSelect={() =>
|
||||
menuArmed
|
||||
? pick(fileCtx.close, () => void act("discard", menuFile.path, true))()
|
||||
: setConfirmDiscard(menuFile.path)
|
||||
}
|
||||
>
|
||||
{menuArmed ? t("changes.discardConfirm") : t("changes.discard")}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
<MenuSeparator />
|
||||
<MenuItem onSelect={pick(fileCtx.close, () => copy(menuFile.path))}>
|
||||
{t("changes.copyPath")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
{treeCtx.menu && (
|
||||
<ContextMenu
|
||||
x={treeCtx.menu.x}
|
||||
y={treeCtx.menu.y}
|
||||
onClose={treeCtx.close}
|
||||
testId="lane-changes-tree-menu"
|
||||
>
|
||||
{!treeCtx.menu.item.dir && (
|
||||
<MenuItem onSelect={pick(treeCtx.close, () => openWhole(treeCtx.menu!.item.path))}>
|
||||
{t("changes.openFile", { path: treeCtx.menu.item.name })}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onSelect={pick(treeCtx.close, () => copy(treeCtx.menu!.item.path))}>
|
||||
{t("changes.copyPath")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
{commitCtx.menu && (
|
||||
<ContextMenu
|
||||
x={commitCtx.menu.x}
|
||||
y={commitCtx.menu.y}
|
||||
onClose={commitCtx.close}
|
||||
testId="lane-changes-commit-menu"
|
||||
>
|
||||
<MenuItem onSelect={pick(commitCtx.close, () => viewCommit(commitCtx.menu!.item.sha))}>
|
||||
{t("changes.viewCommit")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(commitCtx.close, () => copy(commitCtx.menu!.item.sha))}>
|
||||
{t("changes.copySha")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(commitCtx.close, () => copy(commitCtx.menu!.item.subject))}>
|
||||
{t("changes.copySubject")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// One change block, named by its hunk's `@@` line and its index inside that
|
||||
// hunk, matched against a fresh diff at the context this diff was read
|
||||
// with — so the server never applies a stale patch.
|
||||
@@ -1038,6 +1176,7 @@ export function LaneChangesPanel({
|
||||
expanded={expandedDirs}
|
||||
onToggleDir={toggleDir}
|
||||
onOpenFile={openWhole}
|
||||
onMenu={treeCtx.open}
|
||||
active={full && mode === "file" ? openFile : null}
|
||||
/>
|
||||
</ul>
|
||||
@@ -1050,6 +1189,7 @@ export function LaneChangesPanel({
|
||||
data-testid="lane-changes"
|
||||
className={`flex min-h-0 flex-1 ${full ? "gap-3" : "flex-col"}`}
|
||||
>
|
||||
{menus}
|
||||
{/* Full width has room for an explorer: the tree stays beside the file
|
||||
it opened, instead of being a mode you leave to read one. */}
|
||||
{full && (
|
||||
@@ -1278,11 +1418,8 @@ export function LaneChangesPanel({
|
||||
<li key={c.sha}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSha(c.sha);
|
||||
setRange("commit");
|
||||
setMode("review");
|
||||
}}
|
||||
onClick={() => viewCommit(c.sha)}
|
||||
onContextMenu={(e) => commitCtx.open(e, c)}
|
||||
className="w-full rounded px-1 py-0.5 text-left hover:bg-surface-2"
|
||||
>
|
||||
<span className="flex items-baseline gap-1.5">
|
||||
@@ -1432,6 +1569,7 @@ export function LaneChangesPanel({
|
||||
onAct={(...a) => void act(...a)}
|
||||
onArm={setConfirmDiscard}
|
||||
onResolve={resolve}
|
||||
onMenu={fileCtx.open}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
@@ -1603,6 +1741,7 @@ export function LaneChangesPanel({
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => toggleCollapsed(f.path)}
|
||||
onContextMenu={(e) => fileCtx.open(e, f)}
|
||||
onKeyDown={(e) => {
|
||||
// Only the header's own keys: Enter on the path button
|
||||
// inside bubbles here and must open, not fold.
|
||||
|
||||
Reference in New Issue
Block a user