96e4d28f7c
- #69: tmux applies a pane's `-c` only while its server's own cwd exists. The server was forked from the dashboard's cwd (the plugin cache dir), outlives updates, and the old cache dir is pruned — after that every new pane started in a deleted directory, where `claude` died at once ("run not found") and a shell printed getcwd errors. tmux calls now run from `/`, and the pane's sh wrapper cds into its folder itself, which also heals a tmux server that is already stuck. - Changes panel: a workspace's extra folders are sections stacked under the workspace folder (VS Code Source Control style), with Close repository / Close other repositories on a header's right-click. - Chat: URLs and absolute paths are links; Claude Code's `[Image: source:]` line folds into its turn; uploads under `<data dir>/drops/` are listed. - Update check: a checkout holding commits a newer release lacks reports `diverged_from_release` instead of offering a fast-forward that can only fail.
261 lines
9.1 KiB
TypeScript
261 lines
9.1 KiB
TypeScript
/**
|
|
* @file ChatFilePreview.tsx
|
|
* @description A file a Chat pane's session touched, shown the way Claude
|
|
* Desktop shows one: `FilePreview` (name · EXT, Copy path, Close, then the
|
|
* content) and `FileListModal` (a list on the left, the picked file's preview
|
|
* on the right — Claude's Uploads dialog). Content comes only through routes
|
|
* that already guard it: a file inside the workspace through
|
|
* `GET /api/lanes/:id/git/file` (or `/git/raw` for an image), a file under
|
|
* `~/.claude` (a memory file) through `GET /api/cc-config/file`, an attached
|
|
* image through `GET /api/run/drop/:name`. Anything else shows its path only.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Copy, FileText, Loader2, X } from "lucide-react";
|
|
import { api, gitTarget } from "../../lib/api";
|
|
import { copyText } from "../../lib/clipboard";
|
|
import { useDialog } from "../../hooks/useDialog";
|
|
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
|
|
|
/** Where a Chat pane's files can be read from. */
|
|
export interface PreviewScope {
|
|
laneId: number | null;
|
|
/** The workspace's own folder. */
|
|
laneCwd: string;
|
|
/** The pane's folder — the workspace's, or the session's own worktree. */
|
|
cwd: string;
|
|
sessionId: string;
|
|
}
|
|
|
|
const IMAGE_RE = /\.(png|jpe?g|gif|webp)$/i;
|
|
// The drop dir (`<data dir>/drops/`), or the older `<tmp>/agenthub-drops-<uid>/`.
|
|
const DROP_RE =
|
|
/\/(?:agenthub-drops-\d+|drops)\/([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+)$/;
|
|
|
|
export const baseName = (p: string) => p.split("/").pop() || p;
|
|
/** `report.v2.xlsx` → `XLSX`; none → "". */
|
|
export const extOf = (p: string) => {
|
|
const m = /\.([A-Za-z0-9]+)$/.exec(baseName(p));
|
|
return m ? m[1]!.toUpperCase() : "";
|
|
};
|
|
/** An attached file's name without the drop dir's random prefix. */
|
|
export const displayName = (p: string) =>
|
|
DROP_RE.test(p) ? baseName(p).replace(/^[0-9a-f]{16}-?/, "") || baseName(p) : baseName(p);
|
|
|
|
type Content =
|
|
| { kind: "loading" }
|
|
| { kind: "image"; src: string }
|
|
| { kind: "text"; text: string; truncated: boolean }
|
|
| { kind: "none" };
|
|
|
|
function useContent(path: string, scope: PreviewScope): Content {
|
|
const [content, setContent] = useState<Content>({ kind: "loading" });
|
|
useEffect(() => {
|
|
const drop = DROP_RE.exec(path);
|
|
if (drop) {
|
|
setContent(
|
|
IMAGE_RE.test(path)
|
|
? { kind: "image", src: `/api/run/drop/${encodeURIComponent(drop[1]!)}` }
|
|
: { kind: "none" }
|
|
);
|
|
return;
|
|
}
|
|
let live = true;
|
|
const show = (p: Promise<Content>) => {
|
|
setContent({ kind: "loading" });
|
|
p.then((c) => live && setContent(c)).catch(() => live && setContent({ kind: "none" }));
|
|
};
|
|
const base = scope.cwd.replace(/\/+$/, "");
|
|
if (scope.laneId === null || !base || !path.startsWith(`${base}/`)) {
|
|
// Claude Code's own files (memory): the config pages' guarded read.
|
|
if (/\/\.claude\//.test(path) && !IMAGE_RE.test(path)) {
|
|
show(
|
|
api.ccConfig
|
|
.file(path)
|
|
.then((f) => ({ kind: "text", text: f.text, truncated: !!f.truncated }))
|
|
);
|
|
} else setContent({ kind: "none" });
|
|
return () => {
|
|
live = false;
|
|
};
|
|
}
|
|
const rel = path.slice(base.length + 1);
|
|
const target =
|
|
base === scope.laneCwd.replace(/\/+$/, "")
|
|
? scope.laneId
|
|
: gitTarget(scope.laneId, scope.sessionId);
|
|
if (IMAGE_RE.test(path)) {
|
|
setContent({ kind: "image", src: api.lanes.gitRawUrl(target, rel) });
|
|
return;
|
|
}
|
|
show(
|
|
api.lanes
|
|
.gitFile(target, rel)
|
|
.then((f) =>
|
|
f.available && !f.binary
|
|
? { kind: "text", text: f.lines.join("\n"), truncated: f.truncated }
|
|
: { kind: "none" }
|
|
)
|
|
);
|
|
return () => {
|
|
live = false;
|
|
};
|
|
}, [path, scope.cwd, scope.laneCwd, scope.laneId, scope.sessionId]);
|
|
return content;
|
|
}
|
|
|
|
function Body({ path, scope }: { path: string; scope: PreviewScope }) {
|
|
const { t } = useTranslation("run");
|
|
const content = useContent(path, scope);
|
|
if (content.kind === "loading") {
|
|
return (
|
|
<div className="flex flex-1 items-center justify-center">
|
|
<Loader2 className="h-5 w-5 animate-spin text-fg-muted" />
|
|
</div>
|
|
);
|
|
}
|
|
if (content.kind === "image") {
|
|
return (
|
|
<div className="flex flex-1 items-center justify-center overflow-auto p-4">
|
|
<img
|
|
src={content.src}
|
|
alt={baseName(path)}
|
|
className="max-h-full max-w-full object-contain"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
if (content.kind === "none") {
|
|
return (
|
|
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-sm text-fg-muted">
|
|
<FileText className="h-8 w-8" />
|
|
<p>{t("chat.outline.noPreview")}</p>
|
|
<p className="break-all font-mono text-xs">{path}</p>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div className="min-h-0 flex-1 overflow-auto">
|
|
{isMarkdown(path) ? (
|
|
<MarkdownPreview text={content.text} className="p-4" />
|
|
) : (
|
|
<pre className="p-4 font-mono text-xs leading-relaxed text-fg-secondary">
|
|
{content.text}
|
|
</pre>
|
|
)}
|
|
{content.truncated && (
|
|
<p className="px-4 pb-4 text-xs text-fg-muted">{t("chat.outline.truncated")}</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** One file: header (name · EXT, Copy path, Close) over its content. */
|
|
export function FilePreview({
|
|
path,
|
|
scope,
|
|
onClose,
|
|
className = "",
|
|
}: {
|
|
path: string;
|
|
scope: PreviewScope;
|
|
onClose: () => void;
|
|
className?: string;
|
|
}) {
|
|
const { t } = useTranslation("run");
|
|
return (
|
|
<section
|
|
data-testid="chat-file-preview"
|
|
className={`flex min-h-0 min-w-0 flex-col overflow-hidden bg-surface-1 ${className}`}
|
|
>
|
|
<header className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
|
<span className="min-w-0 truncate text-sm text-fg-primary" title={path}>
|
|
{displayName(path).replace(/\.[A-Za-z0-9]+$/, "")}
|
|
</span>
|
|
<span className="text-xs text-fg-muted">{extOf(path)}</span>
|
|
<div className="ml-auto flex items-center gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => void copyText(path)}
|
|
title={t("chat.outline.copyPath", { path })}
|
|
aria-label={t("chat.outline.copyPath", { path })}
|
|
className="flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
|
>
|
|
<Copy className="h-3.5 w-3.5" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
title={t("chat.outline.close")}
|
|
aria-label={t("chat.outline.close")}
|
|
className="flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</header>
|
|
<Body path={path} scope={scope} />
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/** Claude's Uploads dialog: the files on the left, the picked one on the right. */
|
|
export function FileListModal({
|
|
title,
|
|
paths,
|
|
scope,
|
|
onClose,
|
|
}: {
|
|
title: string;
|
|
paths: string[];
|
|
scope: PreviewScope;
|
|
onClose: () => void;
|
|
}) {
|
|
const [picked, setPicked] = useState(paths[0] ?? "");
|
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
useDialog(true, panelRef, onClose);
|
|
// Into document.body, like ImageLightbox: a pane's own stacking layer
|
|
// would otherwise draw over a `fixed` overlay opened from inside it.
|
|
return createPortal(
|
|
<div
|
|
className="fixed inset-0 z-[200] flex items-center justify-center bg-surface-0/70 p-6"
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
ref={panelRef}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={title}
|
|
data-testid="chat-file-modal"
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="flex h-[80vh] w-full max-w-6xl overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl"
|
|
>
|
|
<div className="flex w-80 flex-shrink-0 flex-col border-r border-border">
|
|
<h2 className="px-5 pb-3 pt-5 text-lg text-fg-primary">{title}</h2>
|
|
<ul className="min-h-0 flex-1 space-y-0.5 overflow-y-auto px-2 pb-3">
|
|
{paths.map((p) => (
|
|
<li key={p}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setPicked(p)}
|
|
title={p}
|
|
aria-current={p === picked}
|
|
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm ${p === picked ? "bg-surface-3 text-fg-primary" : "text-fg-secondary hover:bg-surface-2"}`}
|
|
>
|
|
<FileText className="h-4 w-4 flex-shrink-0 text-fg-muted" />
|
|
<span className="min-w-0 truncate">{displayName(p)}</span>
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
{picked && <FilePreview path={picked} scope={scope} onClose={onClose} className="flex-1" />}
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
);
|
|
}
|