daf5b7be92
- Changes panel: a working file opens in a CodeMirror editor with its diff inline (DiffEditor.tsx, @codemirror/merge, lazy-loaded); Changes folds the unchanged lines, Whole file shows them; side by side at 50/50 and full width; a Revert per block; original from HEAD or the branch's merge base (`mergeBase` on GET /git/changes?range=branch) - Every diff: green = line added whole, red = removed whole, blue = edited in place (--diff-mod) - History drawn as a commit graph (#58: `parents` on GET /git/log, --date-order; lib/commitGraph.ts) - Per-workspace never-stage list, drawn orange in the Files tree (--kept); closed Terminal rows past three deleted - Status bar plan quota for Claude (per account) and Codex (GET /api/pricing/quota, server/lib/quota.js); compact hover panel that stays open, Refresh button with a spinner - Settings → Appearance holds language, theme and pet; Ctrl+Shift+C copies; compact sidebar navigation and short session times
193 lines
7.0 KiB
TypeScript
193 lines
7.0 KiB
TypeScript
/**
|
|
* @file WorkspaceRow — one workspace (lane) in the sidebar's workspace list, one
|
|
* line like a Claude Desktop project: a chevron that expands its sessions, the
|
|
* status dot, the name (click opens the workspace, double-click renames,
|
|
* `PATCH /api/lanes/:id {title}`) and on hover `+` (a new
|
|
* session there) and `⋯` (the row menu, `WorkspaceRowMenu.tsx`, also on
|
|
* right-click). Dragging it, or Alt+↑/↓, reorders the list — a viewer
|
|
* preference kept in localStorage (`laneOrderStorage.ts`), never written to
|
|
* the lane row. The row only STARTS a drag (`LANE_DRAG_TYPE`); the list
|
|
* (`WorkspaceTree.tsx`) takes the drop anywhere, gaps included. F2 renames too; the
|
|
* dot's meaning is screen-reader text, and an idle dot is a ring.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useState, type MouseEvent } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { ChevronDown, ChevronRight, MoreHorizontal, Plus } from "lucide-react";
|
|
import type { Lane } from "../../lib/types";
|
|
import { isSubmitEnter } from "../../lib/keys";
|
|
|
|
/** What a workspace row's drag carries: its index in the list. */
|
|
export const LANE_DRAG_TYPE = "application/x-agenthub-lane";
|
|
export const isLaneDrag = (e: { dataTransfer: DataTransfer | null }) =>
|
|
!!e.dataTransfer?.types?.includes(LANE_DRAG_TYPE);
|
|
|
|
/** 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";
|
|
// A ring, not a fill: an idle surface-4 dot vanished on the light themes.
|
|
return "border border-fg-muted";
|
|
}
|
|
|
|
/** What the dot means (`row.state.*`), for a screen reader. */
|
|
export function workspaceDotLabel(lane: Lane): string {
|
|
if (lane.needs_action) return "needsYou";
|
|
if (lane.liveness === "dead" || lane.status === "error") return "error";
|
|
if (lane.status === "running") return "running";
|
|
if (lane.liveness === "active") return "active";
|
|
return "idle";
|
|
}
|
|
|
|
export function workspaceName(lane: Lane): string {
|
|
return lane.title || lane.cwd.split("/").filter(Boolean).pop() || lane.cwd;
|
|
}
|
|
|
|
export function WorkspaceRow({
|
|
lane,
|
|
index,
|
|
selected,
|
|
expanded,
|
|
renaming,
|
|
onSelect,
|
|
onToggle,
|
|
onNewSession,
|
|
onReorder,
|
|
onMenu,
|
|
onRenameStart,
|
|
onRenameEnd,
|
|
}: {
|
|
lane: Lane;
|
|
/** Position in the list — the unit both reorder paths speak in. */
|
|
index: number;
|
|
selected: boolean;
|
|
expanded: boolean;
|
|
renaming: boolean;
|
|
onSelect: () => void;
|
|
onToggle: () => void;
|
|
onNewSession: () => 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 [draft, setDraft] = useState("");
|
|
const name = workspaceName(lane);
|
|
const openMenu = (e: MouseEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onMenu(e.clientX, e.clientY);
|
|
};
|
|
const Chevron = expanded ? ChevronDown : ChevronRight;
|
|
const hoverBtn =
|
|
"shrink-0 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";
|
|
|
|
return (
|
|
<div
|
|
data-testid={`workspace-row-${lane.id}`}
|
|
data-lane-index={index}
|
|
draggable={!renaming}
|
|
onDragStart={(e) => {
|
|
// Firefox starts no drag at all without a setData call.
|
|
e.dataTransfer.setData(LANE_DRAG_TYPE, String(index));
|
|
e.dataTransfer.effectAllowed = "move";
|
|
}}
|
|
onContextMenu={openMenu}
|
|
className={`group flex items-center gap-1 rounded-md pl-1 pr-1 transition-colors ${
|
|
selected
|
|
? "bg-accent/10"
|
|
: lane.needs_action
|
|
? "bg-status-warning/10 hover:bg-status-warning/20"
|
|
: "hover:bg-surface-3"
|
|
}`}
|
|
>
|
|
<button
|
|
type="button"
|
|
data-testid={`workspace-toggle-${lane.id}`}
|
|
aria-expanded={expanded}
|
|
aria-label={name}
|
|
onClick={onToggle}
|
|
className="shrink-0 rounded p-0.5 text-fg-muted hover:text-fg-primary"
|
|
>
|
|
<Chevron className="h-3.5 w-3.5" />
|
|
</button>
|
|
{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 (isSubmitEnter(e)) onRenameEnd(draft.trim());
|
|
if (e.key === "Escape") onRenameEnd(null);
|
|
}}
|
|
className="my-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"
|
|
data-testid={`workspace-open-${lane.id}`}
|
|
aria-current={selected ? "true" : undefined}
|
|
// A double-click's second click renames instead of opening again.
|
|
onClick={(e) => e.detail < 2 && onSelect()}
|
|
onDoubleClick={() => {
|
|
setDraft(lane.title || "");
|
|
onRenameStart();
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "F2") {
|
|
e.preventDefault();
|
|
setDraft(lane.title || "");
|
|
onRenameStart();
|
|
return;
|
|
}
|
|
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-center gap-2 py-1.5 text-left"
|
|
>
|
|
<span aria-hidden className={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
|
|
<span className="sr-only">{t(`row.state.${workspaceDotLabel(lane)}`)}</span>
|
|
<span className="truncate text-[13px] font-medium text-fg-primary">{name}</span>
|
|
</button>
|
|
)}
|
|
{!renaming && (
|
|
<>
|
|
<button
|
|
type="button"
|
|
data-testid={`workspace-new-${lane.id}`}
|
|
aria-label={t("tree.newSession")}
|
|
title={t("tree.newSession")}
|
|
onClick={onNewSession}
|
|
className={hoverBtn}
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
data-testid={`workspace-menu-${lane.id}`}
|
|
aria-label={t("row.actions")}
|
|
title={t("row.actions")}
|
|
onClick={openMenu}
|
|
className={hoverBtn}
|
|
>
|
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|