22de5009ff
- Artifacts (/artifacts): claude.ai pages sessions publish (captured from the Artifact tool's hook), pinned workspace files, skills and plugins, each kept as a snapshot; shared ones appear in a Hub and a skill or plugin installs into the viewer's own account (server/lib/artifacts.js, /api/artifacts, docs/plans/artifacts/). - Workspace extra folders: clone one or more related repos beside the workspace and add them in one step (POST /api/lanes/:id/sources/clone). - A pane that ended by itself while tmux kept running is no longer offered for resume (tmux.serverStartedAt in reconcileOrphans). - Changes panel: a folder that is not a git repo opens on Files, and its files open from there. - Plus the parallel sessions' work in this tree: File Explorer button in the panel header, user delete, RSA SSH keys, chat/terminal and pet fixes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
572 lines
21 KiB
TypeScript
572 lines
21 KiB
TypeScript
/**
|
|
* @file WorkspaceContext — a workspace's context: Instructions, Memory
|
|
* (read-only), Context files and Folders (`GET/PUT/POST/DELETE
|
|
* /api/lanes/:id/sources…`, see server/lib/lane-sources.js). Shown as the
|
|
* workspace home's right column and, `compact`, folded at the bottom of the
|
|
* session view's Changes panel. Folder suggestions are the repo's untracked
|
|
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees; the
|
|
* same form clones related repos beside the workspace (`CloneSourcesForm`).
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { FileText, Folder, Pencil, Plus, X } from "lucide-react";
|
|
import { copyText } from "../../lib/clipboard";
|
|
import { dragProjectPath } from "../../lib/promptAttach";
|
|
import { useDialog } from "../../hooks/useDialog";
|
|
import { api, type LaneMemoryFile, type LaneSource, type LaneSourcesView } from "../../lib/api";
|
|
import type { Lane } from "../../lib/types";
|
|
import { useAiTools } from "../../hooks/useAiTools";
|
|
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
|
|
import { MarkdownPreview } from "../MarkdownPreview";
|
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
|
import { PathPickButton } from "../PathPickButton";
|
|
import { CloneSourcesForm } from "./CloneSourcesForm";
|
|
|
|
function fmtBytes(n: number | null): string {
|
|
if (!n) return "";
|
|
if (n < 1024) return `${n} B`;
|
|
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
|
|
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
}
|
|
|
|
const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
|
|
|
|
/** Keyed by workspace: an open edit or folder form belongs to ONE workspace. */
|
|
export function WorkspaceContext(props: Parameters<typeof ContextBody>[0]) {
|
|
return <ContextBody key={props.lane.id} {...props} />;
|
|
}
|
|
|
|
function ContextBody({
|
|
lane,
|
|
onSourcesChange,
|
|
sourcesVersion = 0,
|
|
compact = false,
|
|
}: {
|
|
lane: Lane;
|
|
/** Called with the sources after a folder or file is added or removed. */
|
|
onSourcesChange?: (view: LaneSourcesView) => void;
|
|
/** Bumped when the folder list changed elsewhere: the lists re-read. */
|
|
sourcesVersion?: number;
|
|
/** The Changes panel's narrow fold: no card, tighter rows. */
|
|
compact?: boolean;
|
|
}) {
|
|
const { t } = useTranslation("lanes");
|
|
const aiTools = useAiTools();
|
|
const prefs = useProjectTreePrefs();
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [view, setView] = useState<LaneSourcesView | null>(null);
|
|
const [editing, setEditing] = useState<string | null>(null);
|
|
const [memory, setMemory] = useState<LaneMemoryFile[] | null>(null);
|
|
const [memoryOpen, setMemoryOpen] = useState(false);
|
|
const [memoryTab, setMemoryTab] = useState(0);
|
|
const [folderOpen, setFolderOpen] = useState(false);
|
|
const [folderPath, setFolderPath] = useState("");
|
|
const [suggestions, setSuggestions] = useState<string[]>([]);
|
|
const fileInput = useRef<HTMLInputElement>(null);
|
|
const [ctxDrag, setCtxDrag] = useState(false);
|
|
const memoryPanel = useRef<HTMLDivElement>(null);
|
|
useDialog(memoryOpen && !!memory, memoryPanel, () => setMemoryOpen(false));
|
|
const sourceCtx = useContextMenu<LaneSource>();
|
|
|
|
useEffect(() => {
|
|
let live = true;
|
|
api.lanes
|
|
.sources(lane.id)
|
|
.then((v) => live && setView(v))
|
|
.catch((err) => live && setError(message(err)));
|
|
api.lanes
|
|
.memory(lane.id)
|
|
.then((r) => live && setMemory(r.files))
|
|
.catch(() => live && setMemory([]));
|
|
return () => {
|
|
live = false;
|
|
};
|
|
}, [lane.id]);
|
|
|
|
// Suggestions are read when the folder form opens, not on every visit.
|
|
useEffect(() => {
|
|
if (!folderOpen) return;
|
|
let live = true;
|
|
api.projects
|
|
.list()
|
|
.then(({ projects }) => {
|
|
if (!live) return;
|
|
const p = projects.find((x) => x.workspaces.includes(lane.id));
|
|
const own = new Set((view?.sources ?? []).map((s) => s.path));
|
|
setSuggestions(
|
|
!p || !prefs.showUntracked
|
|
? []
|
|
: p.worktrees
|
|
.filter((w) => w.workspace_id === null && !own.has(w.path))
|
|
.filter((w) => !(w.source && prefs.hideSources.includes(w.source)))
|
|
.map((w) => w.path)
|
|
);
|
|
})
|
|
.catch(() => undefined);
|
|
return () => {
|
|
live = false;
|
|
};
|
|
}, [folderOpen, lane.id, prefs.showUntracked, prefs.hideSources, view?.sources]);
|
|
|
|
useEffect(() => {
|
|
if (!sourcesVersion) return;
|
|
let live = true;
|
|
api.lanes
|
|
.sources(lane.id)
|
|
.then((v) => live && setView(v))
|
|
.catch(() => undefined);
|
|
return () => {
|
|
live = false;
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- on a bump only
|
|
}, [sourcesVersion]);
|
|
|
|
const run = async (fn: () => Promise<LaneSourcesView>) => {
|
|
setError(null);
|
|
try {
|
|
const next = await fn();
|
|
setView(next);
|
|
onSourcesChange?.(next);
|
|
return true;
|
|
} catch (err) {
|
|
setError(message(err));
|
|
return false;
|
|
}
|
|
};
|
|
const addFiles = async (picked: File[]) => {
|
|
for (const f of picked) {
|
|
if (!(await run(() => api.lanes.addFile(lane.id, f)))) break;
|
|
}
|
|
};
|
|
|
|
const files = (view?.sources ?? []).filter((s) => s.kind === "file");
|
|
const folders = (view?.sources ?? []).filter((s) => s.kind === "folder");
|
|
const card = compact
|
|
? "border-b border-border/60 px-1 py-2 last:border-b-0"
|
|
: "border-b border-border px-4 py-4 last:border-b-0";
|
|
const heading = compact
|
|
? "text-xs font-medium text-fg-secondary"
|
|
: "text-sm font-medium text-fg-primary";
|
|
const iconBtn =
|
|
"rounded p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40";
|
|
|
|
return (
|
|
<>
|
|
<aside
|
|
data-testid="workspace-context"
|
|
aria-label={t("home.contextPanel")}
|
|
className={compact ? "" : "card w-80 shrink-0 self-start p-0"}
|
|
>
|
|
{view && !view.supported && (
|
|
<p className="border-b border-border px-4 py-3 text-xs text-status-warning">
|
|
{t("home.unsupported", { tool: aiTools.label(lane.tool) })}
|
|
</p>
|
|
)}
|
|
{error && (
|
|
<p role="alert" className="border-b border-border px-4 py-2 text-xs text-status-danger">
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
<section className={card}>
|
|
<div className="flex items-center justify-between">
|
|
<h3 className={heading}>{t("home.instructions")}</h3>
|
|
{editing === null && (
|
|
<button
|
|
type="button"
|
|
aria-label={t("home.edit")}
|
|
title={t("home.edit")}
|
|
onClick={() => setEditing(view?.instructions ?? "")}
|
|
disabled={!view}
|
|
className={iconBtn}
|
|
>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
{editing === null ? (
|
|
<p
|
|
className={`mt-1 ${compact ? "line-clamp-2" : "line-clamp-3"} whitespace-pre-wrap text-xs text-fg-muted`}
|
|
>
|
|
{view?.instructions || t("home.instructionsEmpty")}
|
|
</p>
|
|
) : (
|
|
<div className="mt-2 space-y-2">
|
|
<textarea
|
|
data-testid="workspace-instructions"
|
|
aria-label={t("home.instructions")}
|
|
autoFocus
|
|
value={editing}
|
|
maxLength={view?.maxInstructions}
|
|
onChange={(e) => setEditing(e.target.value)}
|
|
rows={compact ? 4 : 6}
|
|
className="w-full rounded border border-border bg-surface-1 p-2 text-xs text-fg-primary"
|
|
/>
|
|
<div className="flex justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditing(null)}
|
|
className="btn-ghost text-xs"
|
|
>
|
|
{t("home.cancel")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={async () => {
|
|
if (await run(() => api.lanes.setInstructions(lane.id, editing)))
|
|
setEditing(null);
|
|
}}
|
|
className="btn-primary text-xs"
|
|
>
|
|
{t("home.save")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<section className={card}>
|
|
<div className="flex items-center justify-between">
|
|
<h3 className={heading}>{t("home.memory")}</h3>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setMemoryTab(0);
|
|
setMemoryOpen(true);
|
|
}}
|
|
disabled={!memory?.length}
|
|
className="text-xs text-fg-secondary hover:text-fg-primary disabled:opacity-40"
|
|
>
|
|
{t("home.viewMemory")}
|
|
</button>
|
|
</div>
|
|
<p className="mt-1 text-xs text-fg-muted">
|
|
{memory?.length
|
|
? t("home.memoryCount", { count: memory.length })
|
|
: t("home.memoryEmpty")}
|
|
</p>
|
|
</section>
|
|
|
|
<section
|
|
data-testid="workspace-context-files"
|
|
className={`${card} ${ctxDrag ? "ring-2 ring-accent/50" : ""}`}
|
|
// Files dragged in from the OS are added like the + button's pick.
|
|
onDragOver={(e) => {
|
|
if (!view || !e.dataTransfer.types.includes("Files")) return;
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = "copy";
|
|
setCtxDrag(true);
|
|
}}
|
|
onDragLeave={(e) => {
|
|
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setCtxDrag(false);
|
|
}}
|
|
onDrop={(e) => {
|
|
setCtxDrag(false);
|
|
if (!view || e.dataTransfer.files.length === 0) return;
|
|
e.preventDefault();
|
|
void addFiles([...e.dataTransfer.files]);
|
|
}}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<h3 className={heading}>{t("home.context")}</h3>
|
|
<button
|
|
type="button"
|
|
aria-label={t("home.addFile")}
|
|
title={t("home.addFile")}
|
|
onClick={() => fileInput.current?.click()}
|
|
disabled={!view}
|
|
className={iconBtn}
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
</button>
|
|
<input
|
|
ref={fileInput}
|
|
data-testid="workspace-file-input"
|
|
type="file"
|
|
multiple
|
|
hidden
|
|
onChange={(e) => {
|
|
const picked = [...(e.target.files ?? [])];
|
|
e.target.value = "";
|
|
void addFiles(picked);
|
|
}}
|
|
/>
|
|
</div>
|
|
{!compact && <p className="mt-1 text-xs text-fg-muted">{t("home.contextSnapshot")}</p>}
|
|
{files.length === 0 ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => fileInput.current?.click()}
|
|
className={`mt-2 w-full rounded-lg border border-dashed border-border px-3 ${compact ? "py-2" : "py-5"} text-center text-xs text-fg-muted hover:border-border-light hover:text-fg-secondary`}
|
|
>
|
|
{t("home.contextEmpty")}
|
|
</button>
|
|
) : (
|
|
<ul className="mt-2 space-y-1">
|
|
{files.map((f) => (
|
|
<li
|
|
key={f.id}
|
|
className="group flex items-center gap-2 text-xs"
|
|
onContextMenu={(e) => sourceCtx.open(e, f)}
|
|
// Dragged onto a prompt box, it is attached as `@<path>`.
|
|
draggable
|
|
onDragStart={(e) => dragProjectPath(e, f.path)}
|
|
>
|
|
<FileText className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
|
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
|
|
{f.name}
|
|
</span>
|
|
<span className="shrink-0 text-fg-muted">
|
|
{f.missing ? t("home.missing") : fmtBytes(f.size)}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
aria-label={`${t("home.remove")} ${f.name}`}
|
|
onClick={() => void run(() => api.lanes.removeSource(lane.id, f.id))}
|
|
className={`${iconBtn} opacity-0 group-hover:opacity-100 focus:opacity-100`}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
|
|
<section className={card}>
|
|
<div className="flex items-center justify-between">
|
|
<h3 className={heading}>{t("home.folder")}</h3>
|
|
<button
|
|
type="button"
|
|
aria-label={t("home.addFolder")}
|
|
title={t("home.addFolder")}
|
|
onClick={() => setFolderOpen((v) => !v)}
|
|
disabled={!view}
|
|
className={iconBtn}
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
<ul className="mt-2 space-y-1.5">
|
|
<li
|
|
className="flex items-start gap-2 text-xs"
|
|
draggable
|
|
onDragStart={(e) => dragProjectPath(e, `${lane.cwd}/`)}
|
|
>
|
|
<Folder className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
|
<span className="min-w-0">
|
|
<span title={lane.cwd} className="block truncate text-fg-secondary">
|
|
{lane.cwd.split("/").filter(Boolean).pop() || lane.cwd}
|
|
</span>
|
|
<span className="block text-fg-muted">{t("home.ownFolder")}</span>
|
|
</span>
|
|
</li>
|
|
{folders.map((f) => (
|
|
<li
|
|
key={f.id}
|
|
className="group flex items-center gap-2 text-xs"
|
|
onContextMenu={(e) => sourceCtx.open(e, f)}
|
|
draggable
|
|
onDragStart={(e) => dragProjectPath(e, `${f.path}/`)}
|
|
>
|
|
<Folder className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
|
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
|
|
{f.name}
|
|
{f.missing && (
|
|
<span className="ml-1 text-status-warning">{t("home.missing")}</span>
|
|
)}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
aria-label={`${t("home.remove")} ${f.name}`}
|
|
onClick={() => void run(() => api.lanes.removeSource(lane.id, f.id))}
|
|
className={`${iconBtn} opacity-0 group-hover:opacity-100 focus:opacity-100`}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
{folderOpen && (
|
|
<form
|
|
className="mt-3 space-y-2"
|
|
onSubmit={async (e) => {
|
|
e.preventDefault();
|
|
if (await run(() => api.lanes.addFolder(lane.id, folderPath.trim()))) {
|
|
setFolderPath("");
|
|
setFolderOpen(false);
|
|
}
|
|
}}
|
|
>
|
|
<div className="flex gap-2">
|
|
<input
|
|
data-testid="workspace-folder-input"
|
|
aria-label={t("home.folderPath")}
|
|
placeholder={t("home.folderPath")}
|
|
autoFocus
|
|
value={folderPath}
|
|
onChange={(e) => setFolderPath(e.target.value)}
|
|
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
|
/>
|
|
<PathPickButton start={folderPath || lane.cwd} onPick={setFolderPath} />
|
|
<button type="submit" disabled={!folderPath.trim()} className="btn-ghost text-xs">
|
|
{t("home.add")}
|
|
</button>
|
|
</div>
|
|
{suggestions.length > 0 && (
|
|
<div>
|
|
<p className="text-[10px] uppercase tracking-wide text-fg-muted">
|
|
{t("home.suggested")}
|
|
</p>
|
|
<ul className="mt-1 space-y-0.5">
|
|
{suggestions.map((p) => (
|
|
<li key={p}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setFolderPath(p)}
|
|
title={p}
|
|
className="w-full truncate rounded px-1 text-left font-mono text-[11px] text-fg-secondary hover:bg-surface-3"
|
|
>
|
|
{p}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</form>
|
|
)}
|
|
{folderOpen && (
|
|
<CloneSourcesForm
|
|
laneId={lane.id}
|
|
parent={lane.cwd.replace(/\/+$/, "").replace(/\/[^/]*$/, "") || "/"}
|
|
onAdded={(next) => {
|
|
setView(next);
|
|
onSourcesChange?.(next);
|
|
}}
|
|
/>
|
|
)}
|
|
</section>
|
|
</aside>
|
|
|
|
{memoryOpen && memory && (
|
|
<div
|
|
className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4"
|
|
role="presentation"
|
|
onClick={() => setMemoryOpen(false)}
|
|
>
|
|
<div
|
|
ref={memoryPanel}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={t("home.memory")}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="flex max-h-[85vh] w-full max-w-3xl flex-col rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
|
|
>
|
|
<div className="mb-3 flex shrink-0 items-center justify-between">
|
|
<h2 className="text-sm font-semibold text-fg-primary">{t("home.memory")}</h2>
|
|
<button
|
|
type="button"
|
|
autoFocus
|
|
aria-label={t("home.close")}
|
|
onClick={() => setMemoryOpen(false)}
|
|
className={iconBtn}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
<div
|
|
role="tablist"
|
|
aria-label={t("home.memory")}
|
|
// Wraps, so every file is reachable without a horizontal wheel (a
|
|
// hidden-scrollbar row cut the rest off); past ~3 rows it scrolls
|
|
// with a visible bar. shrink-0: a long file must not squeeze it.
|
|
className="mb-3 flex max-h-28 shrink-0 flex-wrap gap-1.5 overflow-y-auto border-b border-border/60 pb-2"
|
|
>
|
|
{memory.map((f, i) => (
|
|
<button
|
|
key={f.path}
|
|
type="button"
|
|
role="tab"
|
|
id={`workspace-memory-tab-${i}`}
|
|
aria-controls="workspace-memory-panel"
|
|
tabIndex={i === memoryTab ? 0 : -1}
|
|
title={f.path}
|
|
aria-selected={i === memoryTab}
|
|
onClick={() => setMemoryTab(i)}
|
|
// APG tabs: arrows move (and select), one tab stop.
|
|
onKeyDown={(e) => {
|
|
const n = memory.length;
|
|
const to =
|
|
e.key === "ArrowRight"
|
|
? (i + 1) % n
|
|
: e.key === "ArrowLeft"
|
|
? (i - 1 + n) % n
|
|
: e.key === "Home"
|
|
? 0
|
|
: e.key === "End"
|
|
? n - 1
|
|
: null;
|
|
if (to === null) return;
|
|
e.preventDefault();
|
|
setMemoryTab(to);
|
|
document.getElementById(`workspace-memory-tab-${to}`)?.focus();
|
|
}}
|
|
className={`flex-shrink-0 whitespace-nowrap rounded-lg border px-3 py-1.5 font-mono text-xs transition-colors ${
|
|
i === memoryTab
|
|
? "border-accent/30 bg-accent/15 text-accent"
|
|
: "border-border text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
|
}`}
|
|
>
|
|
{f.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{memory[memoryTab] && (
|
|
<div
|
|
role="tabpanel"
|
|
id="workspace-memory-panel"
|
|
aria-labelledby={`workspace-memory-tab-${memoryTab}`}
|
|
className="min-h-0 flex-1 overflow-y-auto"
|
|
>
|
|
<MarkdownPreview text={memory[memoryTab].content} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{sourceCtx.menu && (
|
|
<ContextMenu
|
|
x={sourceCtx.menu.x}
|
|
y={sourceCtx.menu.y}
|
|
onClose={sourceCtx.close}
|
|
testId={`workspace-source-menu-${sourceCtx.menu.item.id}`}
|
|
>
|
|
<MenuItem
|
|
onSelect={() => {
|
|
void copyText(sourceCtx.menu!.item.path);
|
|
sourceCtx.close();
|
|
}}
|
|
>
|
|
{t("menu.copyPath")}
|
|
</MenuItem>
|
|
<MenuSeparator />
|
|
<MenuItem
|
|
danger
|
|
onSelect={() => {
|
|
const id = sourceCtx.menu!.item.id;
|
|
sourceCtx.close();
|
|
void run(() => api.lanes.removeSource(lane.id, id));
|
|
}}
|
|
>
|
|
{t("home.remove")}
|
|
</MenuItem>
|
|
</ContextMenu>
|
|
)}
|
|
</>
|
|
);
|
|
}
|