feat(ui): sidebar project tree, status bar and a tabbed workbench for workspaces
The Workspace page's lane strip moves into the sidebar as a project tree: workspaces grouped by git repository (new GET /api/projects, cached in memory, POST /api/projects/refresh), untracked worktrees with Adopt, inline rename, drag/Alt+arrow ordering and a right-click row menu. The selected workspace is the URL's ?lane=, one shared lane list feeds the tree, the new bottom status bar and the page. The Changes panel gains an activity bar (Files / Changes / Sessions, with resume), and split panes get a header.
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* @file WorkspaceRow — one workspace (lane) in the sidebar's project tree:
|
||||
* status dot, title (double-click renames, `PATCH /api/lanes/:id {title}`),
|
||||
* branch or relative path, the tool's icon and the id the CLI addresses it by.
|
||||
* Selecting it opens the Workspace page on that lane. Dragging it, or Alt+↑/↓,
|
||||
* reorders it within its project — a viewer preference kept in localStorage
|
||||
* (`laneOrderStorage.ts`), never written to the lane row. Right-click or the
|
||||
* `⋯` button opens the row menu (`WorkspaceRowMenu.tsx`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState, type MouseEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
|
||||
/** The row's dot: the lane's liveness, plus a running pulse and needs-you. */
|
||||
export function workspaceDot(lane: Lane): string {
|
||||
if (lane.needs_action) return "bg-status-warning";
|
||||
if (lane.liveness === "dead" || lane.status === "error") return "bg-status-danger";
|
||||
if (lane.status === "running") return "bg-status-success animate-pulse-slow";
|
||||
if (lane.liveness === "active") return "bg-status-success";
|
||||
return "bg-surface-4";
|
||||
}
|
||||
|
||||
function relativeTo(root: string | null, cwd: string): string {
|
||||
if (root && cwd.startsWith(root + "/")) return cwd.slice(root.length + 1);
|
||||
return cwd;
|
||||
}
|
||||
|
||||
export function WorkspaceRow({
|
||||
lane,
|
||||
projectRoot,
|
||||
branch,
|
||||
index,
|
||||
selected,
|
||||
renaming,
|
||||
onSelect,
|
||||
onReorder,
|
||||
onMenu,
|
||||
onRenameStart,
|
||||
onRenameEnd,
|
||||
}: {
|
||||
lane: Lane;
|
||||
projectRoot: string | null;
|
||||
/** The checked-out branch: the lane's own, else its worktree's (an adopted
|
||||
* lane records none). */
|
||||
branch: string | null;
|
||||
/** Position within its project — the unit both reorder paths speak in. */
|
||||
index: number;
|
||||
selected: boolean;
|
||||
renaming: boolean;
|
||||
onSelect: () => void;
|
||||
onReorder: (from: number, to: number) => void;
|
||||
/** Open the row menu at a viewport point. */
|
||||
onMenu: (x: number, y: number) => void;
|
||||
onRenameStart: () => void;
|
||||
/** Called with the new title on Enter, or null when the edit was cancelled. */
|
||||
onRenameEnd: (title: string | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const tools = useAiTools();
|
||||
const [draft, setDraft] = useState("");
|
||||
const [dropTarget, setDropTarget] = useState(false);
|
||||
const name = lane.title || lane.cwd.split("/").filter(Boolean).pop() || lane.cwd;
|
||||
const openMenu = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onMenu(e.clientX, e.clientY);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={`workspace-row-${lane.id}`}
|
||||
data-drop-target={dropTarget ? "true" : undefined}
|
||||
draggable={!renaming}
|
||||
onDragStart={(e) => {
|
||||
// Firefox starts no drag at all without a setData call.
|
||||
e.dataTransfer.setData("text/plain", String(index));
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDropTarget(true);
|
||||
}}
|
||||
onDragLeave={() => setDropTarget(false)}
|
||||
onDragEnd={() => setDropTarget(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDropTarget(false);
|
||||
const from = Number(e.dataTransfer.getData("text/plain"));
|
||||
if (Number.isInteger(from)) onReorder(from, index);
|
||||
}}
|
||||
onContextMenu={openMenu}
|
||||
className={`group relative flex items-center rounded-md border-l-2 transition-colors ${
|
||||
dropTarget
|
||||
? "border-accent bg-accent/20"
|
||||
: selected
|
||||
? "border-accent bg-accent/10"
|
||||
: lane.needs_action
|
||||
? "border-transparent bg-status-warning/10 hover:bg-status-warning/20"
|
||||
: "border-transparent hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
{renaming ? (
|
||||
<input
|
||||
data-testid={`workspace-rename-${lane.id}`}
|
||||
aria-label={t("renameLabel")}
|
||||
autoFocus
|
||||
value={draft}
|
||||
maxLength={200}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
// Blur CANCELS — Enter is the only save, so one edit fires one PATCH.
|
||||
onBlur={() => onRenameEnd(null)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onRenameEnd(draft.trim());
|
||||
if (e.key === "Escape") onRenameEnd(null);
|
||||
}}
|
||||
className="m-1 min-w-0 flex-1 rounded border border-accent bg-surface-1 px-1.5 py-0.5 text-[13px] text-fg-primary"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
aria-current={selected ? "true" : undefined}
|
||||
onClick={onSelect}
|
||||
onDoubleClick={() => {
|
||||
setDraft(lane.title || "");
|
||||
onRenameStart();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (!e.altKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return;
|
||||
e.preventDefault();
|
||||
onReorder(index, index + (e.key === "ArrowUp" ? -1 : 1));
|
||||
}}
|
||||
title={`${lane.cwd}\n${t("row.hint")}`}
|
||||
className="flex min-w-0 flex-1 items-start gap-2 py-1 pl-2 pr-7 text-left"
|
||||
>
|
||||
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="truncate text-[13px] font-medium text-fg-primary">{name}</span>
|
||||
{projectRoot !== null && lane.cwd === projectRoot && (
|
||||
<span className="shrink-0 rounded border border-border px-1 text-[10px] text-fg-muted">
|
||||
{t("row.primary")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-[11px] text-fg-muted">
|
||||
<span className="min-w-0 truncate">
|
||||
{branch || relativeTo(projectRoot, lane.cwd)}
|
||||
</span>
|
||||
<AiToolIcon
|
||||
tool={{ id: lane.tool, label: tools.label(lane.tool) }}
|
||||
className="ml-auto h-3.5 w-3.5 !p-0"
|
||||
/>
|
||||
<span className="shrink-0 tabular-nums">#{lane.id}</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{!renaming && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-menu-${lane.id}`}
|
||||
aria-label={t("row.actions")}
|
||||
title={t("row.actions")}
|
||||
onClick={openMenu}
|
||||
className="absolute right-1 top-1 rounded p-0.5 text-fg-muted opacity-0 transition-opacity hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover:opacity-100"
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user