mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
p2 (Pi Mobile on Chord v2) moves from pi-private into pi as one commit. Root scripts that loop all workspaces use --if-present; npm test, ./test.sh and the release script cover packages/* only (p2: cd apps/p2 && npm test); sync-versions also updates apps' workspace pins.
1328 lines
49 KiB
TypeScript
1328 lines
49 KiB
TypeScript
import type { Held, Location } from "@p2/app/v1";
|
|
import { BACKGROUND_CONTEXT, filePreview, follow } from "@p2/app/v1";
|
|
import {
|
|
ChevronIcon,
|
|
CloseIcon,
|
|
CommentIcon,
|
|
DiffIcon,
|
|
FileIcon,
|
|
FolderIcon,
|
|
type Navigation,
|
|
QueryCache,
|
|
RefreshIcon,
|
|
routeKey,
|
|
SearchIcon,
|
|
Slot,
|
|
TerminalIcon,
|
|
useScrollMemory,
|
|
type WorkspaceFileRoute,
|
|
type WorkspaceRoute,
|
|
type WorkspaceTab,
|
|
} from "@p2/app/v1/web";
|
|
import {
|
|
type CSSProperties,
|
|
type Dispatch,
|
|
type ReactNode,
|
|
type SetStateAction,
|
|
useCallback,
|
|
useDeferredValue,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import type { Workspace } from "./contract.ts";
|
|
import { type CommentEditor, type FileView, type ReviewComment, reviewMessage, WorkspaceFile } from "./file.tsx";
|
|
import { type FuzzyMatch, rankFiles } from "./fuzzy.ts";
|
|
import type { WorkspaceChange, WorkspaceCommit, WorkspaceQuery, WorkspaceResult } from "./protocol.ts";
|
|
|
|
const LOG_PAGE = 30;
|
|
/** Name filter results shown, best first. */
|
|
const FILTER_RESULTS = 200;
|
|
/** Narrowest chat and file column for which a file opens beside the list instead of in its place. */
|
|
const SPLIT_MIN = 480;
|
|
/** Workspace answers by session, conversation and query (see WorkspacePanel's load). */
|
|
const results = new QueryCache<unknown>(300);
|
|
/** Expanded folders of the Files tree by session and conversation, kept while the page lives. */
|
|
const expandedFolders = new Map<string, ReadonlySet<string>>();
|
|
|
|
/** `text` with the matched ranges emphasized; ranges may run past its end. */
|
|
function Highlight({ text, matches }: { text: string; matches?: FuzzyMatch[] }) {
|
|
const parts: ReactNode[] = [];
|
|
let at = 0;
|
|
for (const match of matches ?? []) {
|
|
const start = Math.max(at, match.start);
|
|
const end = Math.min(text.length, match.end);
|
|
if (end <= start) continue;
|
|
if (start > at) parts.push(text.slice(at, start));
|
|
parts.push(
|
|
<strong key={start} className="fuzzy-hit">
|
|
{text.slice(start, end)}
|
|
</strong>,
|
|
);
|
|
at = end;
|
|
}
|
|
parts.push(text.slice(at));
|
|
return <>{parts}</>;
|
|
}
|
|
|
|
/**
|
|
* The conversation's answers that a change in its working tree can alter. A commit and a file or diff at a commit
|
|
* (`file:<hash>:path`, `diff:<mode>:<hash>:path`) stay valid.
|
|
*/
|
|
function invalidate(sessionId: string, conversationId: number): void {
|
|
const scope = `${sessionId}\0${conversationId}\0`;
|
|
results.invalidate(
|
|
(key) => key.startsWith(scope) && !/^(commit:|file:[^:]|diff:[^:]*:[^:])/.test(key.slice(scope.length)),
|
|
);
|
|
}
|
|
|
|
function ago(time: number): string {
|
|
const seconds = Math.max(0, (Date.now() - time) / 1000);
|
|
if (seconds < 3600) return `${Math.max(1, Math.floor(seconds / 60))}m`;
|
|
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
|
|
if (seconds < 30 * 86400) return `${Math.floor(seconds / 86400)}d`;
|
|
return new Date(time).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "2-digit" });
|
|
}
|
|
/** `key` identifies what was requested: a refetch of the same key keeps showing the old value. */
|
|
type Loaded<T> = { key?: string; value?: T; error?: string; loading: boolean };
|
|
type ChangeNode = { name: string; path: string; children: ChangeNode[]; change?: WorkspaceChange };
|
|
|
|
function size(bytes: number): string {
|
|
return bytes < 1024
|
|
? `${bytes} B`
|
|
: bytes < 1024 * 1024
|
|
? `${(bytes / 1024).toFixed(1)} KB`
|
|
: `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
}
|
|
|
|
/** What a status letter pair means, for the change list's tooltip. */
|
|
function describe(status: string): string {
|
|
if (status === "??") return "untracked";
|
|
if (status.includes("U") || status === "AA" || status === "DD") return "conflict";
|
|
const letter = status[1] !== "." ? status[1] : status[0];
|
|
const staged = status[0] !== "." ? "staged " : "";
|
|
return `${staged}${{ M: "modified", A: "added", D: "deleted", R: "renamed", C: "copied", T: "type changed" }[letter ?? ""] ?? "changed"}`;
|
|
}
|
|
|
|
function letter(status: string): string {
|
|
return status === "??" ? "?" : ((status[1] !== "." ? status[1] : status[0]) ?? "?");
|
|
}
|
|
|
|
/** Changed paths as a directory tree; single-child directory chains merge into one row (`src/web`). */
|
|
export function changeTree(changes: readonly WorkspaceChange[]): ChangeNode[] {
|
|
const root: ChangeNode = { name: "", path: "", children: [] };
|
|
for (const change of changes) {
|
|
let node = root;
|
|
const segments = change.path.split("/");
|
|
segments.forEach((segment, index) => {
|
|
const path = segments.slice(0, index + 1).join("/");
|
|
let child = node.children.find(
|
|
(entry) => entry.name === segment && !entry.change === index < segments.length - 1,
|
|
);
|
|
if (!child) {
|
|
child = { name: segment, path, children: [] };
|
|
node.children.push(child);
|
|
}
|
|
if (index === segments.length - 1) child.change = change;
|
|
node = child;
|
|
});
|
|
}
|
|
const compact = (node: ChangeNode): ChangeNode => {
|
|
let current = node;
|
|
while (!current.change && current.children.length === 1 && !current.children[0]?.change) {
|
|
const only = current.children[0] as ChangeNode;
|
|
current = { ...only, name: `${current.name}/${only.name}` };
|
|
}
|
|
return {
|
|
...current,
|
|
children: current.children
|
|
.map(compact)
|
|
.sort((a, b) => Number(Boolean(a.change)) - Number(Boolean(b.change)) || a.name.localeCompare(b.name)),
|
|
};
|
|
};
|
|
// The root itself never merges into its only child.
|
|
return compact({ ...root, change: { path: "", status: "" } }).children;
|
|
}
|
|
|
|
function initialView(path: string): FileView {
|
|
const kind = filePreview(path)?.kind;
|
|
return kind === "image" || kind === "audio" || kind === "video" || /\.(md|markdown)$/i.test(path)
|
|
? "preview"
|
|
: "file";
|
|
}
|
|
|
|
// Right-hand panel for the viewed conversation's working directory. All data comes
|
|
// from that conversation's execution environment via the worker; media and downloads
|
|
// use the separately authorized /workspace-file endpoint. Where it is (tab, folder,
|
|
// file, commit, search) is part of the route: folders, files and commits are steps.
|
|
export function WorkspacePanel({
|
|
workspace,
|
|
nav,
|
|
route,
|
|
sessionId,
|
|
conversationId,
|
|
attached,
|
|
unavailable,
|
|
onRetry,
|
|
cwd,
|
|
where,
|
|
label,
|
|
owner,
|
|
onSend,
|
|
}: {
|
|
workspace: Held<typeof Workspace> | undefined;
|
|
nav: Navigation;
|
|
route: WorkspaceRoute;
|
|
sessionId: string;
|
|
conversationId: number;
|
|
/** The conversation's snapshot arrived: the server answers workspace queries only for the viewed conversation. */
|
|
attached: boolean;
|
|
/** Why the workspace could not be opened, while it is not attached; with `onRetry` to open it again. */
|
|
unavailable?: string;
|
|
onRetry?: () => void;
|
|
cwd: string;
|
|
/** Where the conversation works, as people read it (`gpu:/path`). */
|
|
where?: string;
|
|
/** A location as people read it: the header shows where the workspace is really open once it answered. */
|
|
label?: (location: Location) => string;
|
|
/** Terminals are a shell on the session's machine: only the owner gets them. */
|
|
owner: boolean;
|
|
/** Deliver review comments as the user's next message; absent for viewers who cannot send. */
|
|
onSend?: (text: string) => Promise<void>;
|
|
}) {
|
|
const { tab, directory, file: selection, commit: openCommit, search: submitted } = route;
|
|
// Docked with room for a file beside the list (measured below): Files, Changes and Search then show the open file
|
|
// to the left of their list instead of in its place. Phones and narrow windows keep one column.
|
|
const [roomy, setRoomy] = useState(false);
|
|
const split = roomy && selection !== undefined && (tab === "files" || tab === "changes" || tab === "search");
|
|
// Changes applied to the latest route, not this render's: queued steps may be pending.
|
|
const update = (change: (current: WorkspaceRoute) => WorkspaceRoute) => (current: typeof nav.route) => ({
|
|
...current,
|
|
...(current.workspace ? { workspace: change(current.workspace) } : {}),
|
|
});
|
|
const step = (next: Partial<WorkspaceRoute>) =>
|
|
void nav.push(update((current) => ({ ...current, ...next }))(nav.route));
|
|
// Folders expand in place (not a step). The last expanded folder is where uploads go; collapsing it or a folder
|
|
// around it moves that to the collapsed folder's parent.
|
|
const toggleFolder = (path: string) => {
|
|
const open = !expanded.has(path);
|
|
const next = new Set(expanded);
|
|
if (open) {
|
|
next.add(path);
|
|
prefetch(`list:${path}`, { kind: "list", path });
|
|
} else next.delete(path);
|
|
setExpanded(next);
|
|
const current = nav.route.workspace?.directory ?? "";
|
|
const target = open
|
|
? path
|
|
: current === path || current.startsWith(`${path}/`)
|
|
? path.slice(0, Math.max(0, path.lastIndexOf("/")))
|
|
: current;
|
|
if (target !== current) void nav.replace(update((workspace) => ({ ...workspace, directory: target }))(nav.route));
|
|
};
|
|
// Opening a file is a step; leaving it goes up to the list it was opened from.
|
|
const setSelection = (next: WorkspaceFileRoute | undefined) => {
|
|
if (next && selection && routeKey(next) === routeKey(selection)) return;
|
|
if (next?.view === "changes")
|
|
prefetch(`diff:context-3:${next.commit ?? ""}:${next.path}`, {
|
|
kind: "diff",
|
|
path: next.path,
|
|
context: 3,
|
|
...(next.commit ? { commit: next.commit } : {}),
|
|
});
|
|
else if (next)
|
|
prefetch(`file:${next.commit ?? ""}:${next.path}`, {
|
|
kind: "file",
|
|
path: next.path,
|
|
...(next.commit ? { commit: next.commit } : {}),
|
|
});
|
|
if (next) step({ file: next });
|
|
else void nav.close(update((current) => ({ ...current, file: undefined })));
|
|
};
|
|
const setOpenCommit = (hash: string | undefined) =>
|
|
hash
|
|
? step({ commit: hash, file: undefined })
|
|
: void nav.close(update((current) => ({ ...current, commit: undefined, file: undefined })));
|
|
const setSubmitted = (search: WorkspaceRoute["search"]) =>
|
|
void nav.replace(update((current) => ({ ...current, search }))(nav.route));
|
|
// Tabs are lateral: switching returns to where the panel opened, keeping each tab's
|
|
// folder and search but dropping files and commits visited meanwhile.
|
|
const setTab = (next: WorkspaceTab) => {
|
|
const current = nav.route.workspace;
|
|
if (!current) return;
|
|
const target: WorkspaceRoute = {
|
|
tab: next,
|
|
directory: current.directory,
|
|
...(current.search ? { search: current.search } : {}),
|
|
...(current.terminal ? { terminal: current.terminal } : {}),
|
|
...(next === "terminal" ? { startTerminal: true } : {}),
|
|
};
|
|
void nav.reset(
|
|
(entry) => entry.workspace !== undefined && entry.layers.length === 0,
|
|
(landed) => ({ ...landed, workspace: target }),
|
|
);
|
|
};
|
|
const [contextLines, setContextLines] = useState(3);
|
|
// Inputs start from the submitted search a restored entry carries.
|
|
const [searchText, setSearchText] = useState(submitted?.text ?? "");
|
|
const [include, setInclude] = useState(submitted?.include ?? "");
|
|
const [exclude, setExclude] = useState(submitted?.exclude ?? "");
|
|
const [filters, setFilters] = useState(Boolean(submitted?.include || submitted?.exclude));
|
|
const searchKey = `search:${JSON.stringify(submitted)}`;
|
|
const [search, setSearch] = useState<Loaded<Extract<WorkspaceResult, { kind: "search" }>>>({ loading: false });
|
|
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set());
|
|
const [wrap, setWrap] = useState(() => localStorage.getItem("pi-workspace-wrap") === "1");
|
|
const [version, setVersion] = useState(0);
|
|
// The Files tree: every shown folder's listing, by path ("" is the root).
|
|
const [listings, setListings] = useState<Record<string, Loaded<Extract<WorkspaceResult, { kind: "list" }>>>>({});
|
|
const folderKey = `${sessionId}\0${conversationId}`;
|
|
const [expanded, setExpandedState] = useState<ReadonlySet<string>>(new Set());
|
|
// A restored route's folder shows expanded, with the folders around it.
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: only when the conversation changes, see below.
|
|
useEffect(() => {
|
|
const next = new Set(expandedFolders.get(folderKey));
|
|
const segments = directory ? directory.split("/") : [];
|
|
for (let index = 1; index <= segments.length; index++) next.add(segments.slice(0, index).join("/"));
|
|
setExpandedState(next);
|
|
// Only when the conversation changes: later folder changes come from the tree itself.
|
|
}, [folderKey]);
|
|
const setExpanded = (next: ReadonlySet<string>) => {
|
|
setExpandedState(next);
|
|
expandedFolders.set(folderKey, next);
|
|
};
|
|
const [fileFilter, setFileFilter] = useState("");
|
|
const filterText = useDeferredValue(fileFilter.trim() ? fileFilter : "");
|
|
const [fileIndex, setFileIndex] = useState<Loaded<Extract<WorkspaceResult, { kind: "files" }>>>({ loading: false });
|
|
const [status, setStatus] = useState<Loaded<Extract<WorkspaceResult, { kind: "status" }>>>({ loading: true });
|
|
const [place, setPlace] = useState<Loaded<Extract<WorkspaceResult, { kind: "place" }>>>({ loading: true });
|
|
const [file, setFile] = useState<Loaded<Extract<WorkspaceResult, { kind: "file" }>>>({ loading: true });
|
|
const [diff, setDiff] = useState<Loaded<Extract<WorkspaceResult, { kind: "diff" }>>>({ loading: true });
|
|
// Draft review comments survive reloads, per conversation, until sent or discarded.
|
|
const commentsKey = `pi-review:${sessionId}:${conversationId}`;
|
|
const [comments, setCommentsState] = useState<ReviewComment[]>([]);
|
|
useEffect(() => {
|
|
try {
|
|
const saved: unknown = JSON.parse(localStorage.getItem(commentsKey) ?? "[]");
|
|
setCommentsState(Array.isArray(saved) ? (saved as ReviewComment[]) : []);
|
|
} catch {
|
|
setCommentsState([]);
|
|
}
|
|
}, [commentsKey]);
|
|
const setComments = (next: ReviewComment[]) => {
|
|
setCommentsState(next);
|
|
if (next.length) localStorage.setItem(commentsKey, JSON.stringify(next));
|
|
else localStorage.removeItem(commentsKey);
|
|
};
|
|
// The open comment box (new or edit) is kept too, so a reload mid-typing loses nothing.
|
|
const editorKey = `pi-review-editor:${sessionId}:${conversationId}`;
|
|
const [editor, setEditorState] = useState<CommentEditor>();
|
|
useEffect(() => {
|
|
try {
|
|
const saved: unknown = JSON.parse(localStorage.getItem(editorKey) ?? "null");
|
|
setEditorState(saved && typeof saved === "object" ? (saved as CommentEditor) : undefined);
|
|
} catch {
|
|
setEditorState(undefined);
|
|
}
|
|
}, [editorKey]);
|
|
const setEditor = (next: CommentEditor | undefined) => {
|
|
setEditorState(next);
|
|
if (next) localStorage.setItem(editorKey, JSON.stringify(next));
|
|
else localStorage.removeItem(editorKey);
|
|
};
|
|
const commented = new Set(comments.map((comment) => comment.path));
|
|
// Sorted by file and line, so the agent reads them in order; sent comments leave the drafts.
|
|
const send = (batch: ReviewComment[]) => {
|
|
if (!onSend || sending || !batch.length) return;
|
|
setSending(true);
|
|
const ids = new Set(batch.map((comment) => comment.id));
|
|
onSend(reviewMessage(batch.toSorted((a, b) => a.path.localeCompare(b.path) || a.end - b.end)))
|
|
.then(() => setComments(comments.filter((comment) => !ids.has(comment.id))))
|
|
.catch(() => {})
|
|
.finally(() => setSending(false));
|
|
};
|
|
const [sending, setSending] = useState(false);
|
|
// Branch history, paged; a live refresh reloads as many commits as are shown.
|
|
const [log, setLog] = useState<{ commits: WorkspaceCommit[]; more: boolean; upstream?: string; error?: string }>({
|
|
commits: [],
|
|
more: false,
|
|
});
|
|
const shown = useRef(LOG_PAGE);
|
|
const [commitDetail, setCommitDetail] = useState<Loaded<Extract<WorkspaceResult, { kind: "commit" }>>>({
|
|
loading: false,
|
|
});
|
|
const [uploading, setUploading] = useState<string>();
|
|
const [uploadError, setUploadError] = useState<string>();
|
|
// Until the conversation is attached, queries wait instead of failing; attaching
|
|
// changes this function, so every load runs again.
|
|
const query = useCallback(
|
|
async <T extends WorkspaceResult>(value: WorkspaceQuery): Promise<T> => {
|
|
if (!workspace || !attached) return new Promise<T>(() => {});
|
|
return (await workspace.query(value, BACKGROUND_CONTEXT)) as T;
|
|
},
|
|
[workspace, attached],
|
|
);
|
|
|
|
const fileUrl = (path: string) =>
|
|
`/workspace-file?${new URLSearchParams({ session: sessionId, conversation: String(conversationId), path })}`;
|
|
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: another file starts with the default context.
|
|
useEffect(() => setContextLines(3), [selection?.path]);
|
|
|
|
// Native handle lifetime leases the watch; changes invalidate the same mobile cache without a heartbeat request.
|
|
useEffect(() => {
|
|
invalidate(sessionId, conversationId);
|
|
let first = true;
|
|
let pending: ReturnType<typeof setTimeout> | undefined;
|
|
if (!workspace) return;
|
|
const stop = follow(workspace.changes, () => {
|
|
if (first) {
|
|
first = false;
|
|
return;
|
|
}
|
|
invalidate(sessionId, conversationId);
|
|
clearTimeout(pending);
|
|
pending = setTimeout(() => setVersion((current) => current + 1), 500);
|
|
});
|
|
return () => {
|
|
clearTimeout(pending);
|
|
stop?.();
|
|
};
|
|
}, [workspace, sessionId, conversationId]);
|
|
|
|
// Answers are kept per conversation (results): Back and revisits show a fresh one without a request and a stale one
|
|
// at once while it refreshes, and a tap's request (prefetch) is joined instead of repeated. Nothing is cached before
|
|
// the conversation is attached, when queries wait without answering.
|
|
const scoped = (key: string) => `${sessionId}\0${conversationId}\0${key}`;
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: scoped is recreated each render and depends only on sessionId and conversationId.
|
|
const load = useCallback(
|
|
<T,>(key: string, request: () => Promise<T>, set: Dispatch<SetStateAction<Loaded<T>>>) => {
|
|
let current = true;
|
|
const cached = attached ? (results.get(scoped(key)) as T | undefined) : undefined;
|
|
if (cached !== undefined && results.fresh(scoped(key))) {
|
|
set({ key, value: cached, loading: false });
|
|
return;
|
|
}
|
|
// Live refreshes keep showing the previous value instead of flashing a spinner.
|
|
set((previous) =>
|
|
previous.key === key
|
|
? { ...previous, loading: true }
|
|
: cached !== undefined
|
|
? { key, value: cached, loading: true }
|
|
: { key, loading: true },
|
|
);
|
|
(attached ? (results.fetch(scoped(key), request) as Promise<T>) : request()).then(
|
|
(value) => current && set({ key, value, loading: false }),
|
|
(error: Error) => current && set({ key, error: error.message, loading: false }),
|
|
);
|
|
return () => {
|
|
current = false;
|
|
};
|
|
},
|
|
[attached, sessionId, conversationId],
|
|
);
|
|
/** Start loading what a tap opens, together with its navigation step. */
|
|
const prefetch = (key: string, value: WorkspaceQuery) => {
|
|
if (attached && !results.fresh(scoped(key))) results.prefetch(scoped(key), () => query(value));
|
|
};
|
|
// Only what is shown loads: a change under an open file reloads the file, not the folder listing behind it.
|
|
const showsListing = tab === "files" && (!selection || split);
|
|
const showsSearch = tab === "search" && (!selection || split);
|
|
// Folders the tree shows: the root and each expanded folder inside a shown one that lists it.
|
|
const shownFolders: string[] = [""];
|
|
for (let index = 0; index < shownFolders.length; index++) {
|
|
const folder = shownFolders[index] ?? "";
|
|
for (const entry of listings[folder]?.value?.entries ?? []) {
|
|
const path = folder ? `${folder}/${entry.name}` : entry.name;
|
|
if (entry.kind === "directory" && expanded.has(path)) shownFolders.push(path);
|
|
}
|
|
}
|
|
const shownKey = shownFolders.join("\0");
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: version triggers refetches.
|
|
useEffect(() => {
|
|
if (!showsListing || filterText) return;
|
|
const stops = shownKey.split("\0").map((path) =>
|
|
load(
|
|
`list:${path}`,
|
|
() => query<Extract<WorkspaceResult, { kind: "list" }>>({ kind: "list", path }),
|
|
(action) =>
|
|
setListings((current) => ({
|
|
...current,
|
|
[path]: typeof action === "function" ? action(current[path] ?? { loading: true }) : action,
|
|
})),
|
|
),
|
|
);
|
|
return () => {
|
|
for (const stop of stops) stop?.();
|
|
};
|
|
}, [shownKey, load, query, version, showsListing, filterText]);
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: version triggers refetches.
|
|
useEffect(() => {
|
|
if (!showsListing || !filterText) return;
|
|
return load("files", () => query<Extract<WorkspaceResult, { kind: "files" }>>({ kind: "files" }), setFileIndex);
|
|
}, [load, query, version, showsListing, filterText]);
|
|
const filePaths = fileIndex.value?.paths;
|
|
const ranked = useMemo(
|
|
() => (filePaths && filterText ? rankFiles(filePaths, filterText, FILTER_RESULTS) : []),
|
|
[filePaths, filterText],
|
|
);
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: version triggers refetches.
|
|
useEffect(
|
|
() => load("status", () => query<Extract<WorkspaceResult, { kind: "status" }>>({ kind: "status" }), setStatus),
|
|
[load, query, version],
|
|
);
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: version triggers refetches.
|
|
useEffect(
|
|
() => load("place", () => query<Extract<WorkspaceResult, { kind: "place" }>>({ kind: "place" }), setPlace),
|
|
[load, query, version],
|
|
);
|
|
const opened = place.value?.location;
|
|
const shownWhere = opened && label ? label(opened) : where;
|
|
const moveNote = place.value?.failed
|
|
? `Moving the session to ${label ? label(place.value.failed.to) : place.value.failed.to.cwd} failed: ${place.value.failed.error}`
|
|
: place.value?.pending
|
|
? `The session moves to ${label ? label(place.value.pending) : place.value.pending.cwd} when its turn ends.`
|
|
: undefined;
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: version triggers refetches.
|
|
useEffect(() => {
|
|
if (tab !== "changes") return;
|
|
let current = true;
|
|
query<Extract<WorkspaceResult, { kind: "log" }>>({
|
|
kind: "log",
|
|
skip: 0,
|
|
limit: Math.min(shown.current, 500),
|
|
}).then(
|
|
(value) => current && setLog({ commits: value.commits, more: value.more, upstream: value.upstream }),
|
|
(error: Error) => current && setLog((previous) => ({ ...previous, error: error.message })),
|
|
);
|
|
return () => {
|
|
current = false;
|
|
};
|
|
}, [query, tab, version]);
|
|
const loadMore = () => {
|
|
const skip = log.commits.length;
|
|
query<Extract<WorkspaceResult, { kind: "log" }>>({ kind: "log", skip, limit: LOG_PAGE }).then(
|
|
(value) => {
|
|
shown.current = skip + value.commits.length;
|
|
setLog((previous) => ({
|
|
...previous,
|
|
commits: [...previous.commits.slice(0, skip), ...value.commits],
|
|
more: value.more,
|
|
}));
|
|
},
|
|
(error: Error) => setLog((previous) => ({ ...previous, error: error.message })),
|
|
);
|
|
};
|
|
useEffect(() => {
|
|
if (!openCommit) return;
|
|
return load(
|
|
`commit:${openCommit}`,
|
|
() => query<Extract<WorkspaceResult, { kind: "commit" }>>({ kind: "commit", commit: openCommit }),
|
|
setCommitDetail,
|
|
);
|
|
}, [load, query, openCommit]);
|
|
const upload = async (picked: File[]) => {
|
|
setUploadError(undefined);
|
|
for (const entry of picked) {
|
|
setUploading(entry.name);
|
|
const params = new URLSearchParams({
|
|
session: sessionId,
|
|
conversation: String(conversationId),
|
|
path: directory,
|
|
name: entry.name,
|
|
});
|
|
try {
|
|
const response = await fetch(`/workspace-upload?${params}`, {
|
|
method: "POST",
|
|
body: entry,
|
|
credentials: "same-origin",
|
|
});
|
|
const result = (await response.json().catch(() => ({}))) as { error?: string };
|
|
if (!response.ok) throw new Error(result.error ?? `Upload failed (${response.status})`);
|
|
} catch (error) {
|
|
setUploadError(`${entry.name}: ${error instanceof Error ? error.message : "upload failed"}`);
|
|
break;
|
|
}
|
|
}
|
|
setUploading(undefined);
|
|
invalidate(sessionId, conversationId);
|
|
setVersion((current) => current + 1);
|
|
};
|
|
const changes = status.value?.kind === "status" && status.value.repo ? status.value.changes : [];
|
|
const commitChanges =
|
|
commitDetail.key === `commit:${openCommit}` && commitDetail.value ? commitDetail.value.changes : [];
|
|
const selectedChange = selection
|
|
? (selection.commit ? commitChanges : changes).find((entry) => entry.path === selection.path)
|
|
: undefined;
|
|
// File and Preview need the file (unless deleted); Changes and a changed File need a diff.
|
|
const needsFile = selection !== undefined && selection.view !== "changes" && !selectedChange?.status.includes("D");
|
|
const diffMode =
|
|
selection?.view === "changes"
|
|
? `context-${contextLines}`
|
|
: selection?.view === "file" && selectedChange
|
|
? "full"
|
|
: undefined;
|
|
const at = selection?.commit;
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed by the selected path, not the selection object; version triggers refetches.
|
|
useEffect(() => {
|
|
if (!selection || !needsFile) return;
|
|
return load(
|
|
`file:${at ?? ""}:${selection.path}`,
|
|
() =>
|
|
query<Extract<WorkspaceResult, { kind: "file" }>>({
|
|
kind: "file",
|
|
path: selection.path,
|
|
...(at ? { commit: at } : {}),
|
|
}),
|
|
setFile,
|
|
);
|
|
}, [load, query, selection?.path, at, needsFile, version]);
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed by the selected path, not the selection object; version triggers refetches.
|
|
useEffect(() => {
|
|
if (!selection || !diffMode) return;
|
|
return load(
|
|
`diff:${diffMode}:${at ?? ""}:${selection.path}`,
|
|
() =>
|
|
query<Extract<WorkspaceResult, { kind: "diff" }>>({
|
|
kind: "diff",
|
|
path: selection.path,
|
|
...(diffMode === "full" ? { full: true } : { context: contextLines }),
|
|
...(at ? { commit: at } : {}),
|
|
}),
|
|
setDiff,
|
|
);
|
|
}, [load, query, selection?.path, at, diffMode, contextLines, version]);
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: version triggers refetches.
|
|
useEffect(() => {
|
|
if (!submitted || !showsSearch) return;
|
|
return load(
|
|
searchKey,
|
|
() =>
|
|
query<Extract<WorkspaceResult, { kind: "search" }>>({
|
|
kind: "search",
|
|
text: submitted.text,
|
|
path: "",
|
|
...(submitted.include ? { include: submitted.include } : {}),
|
|
...(submitted.exclude ? { exclude: submitted.exclude } : {}),
|
|
}),
|
|
setSearch,
|
|
);
|
|
}, [load, query, submitted, searchKey, version, showsSearch]);
|
|
|
|
// Docked width, dragged from the left edge and remembered; phones ignore it (full screen).
|
|
const panel = useRef<HTMLElement>(null);
|
|
// Each list, file and commit keeps its scroll position for Back. Keyed by the panel's
|
|
// location only: a sheet opened beside the docked panel leaves it where it was.
|
|
const scroller = useRef<HTMLDivElement>(null);
|
|
// Beside an open file, the list is the same step as without it.
|
|
// The upload folder changes without a step: it is not part of the location.
|
|
const located = { ...route, directory: "" };
|
|
useScrollMemory(
|
|
scroller,
|
|
routeKey({ sessionId, conversationId, route: split ? { ...located, file: undefined } : located }),
|
|
);
|
|
const previewScroller = useRef<HTMLDivElement>(null);
|
|
useScrollMemory(previewScroller, routeKey({ sessionId, conversationId, route: located, preview: split }));
|
|
// A changed filter starts its results at the top.
|
|
const shownFilter = useRef(filterText);
|
|
useEffect(() => {
|
|
if (shownFilter.current === filterText) return;
|
|
shownFilter.current = filterText;
|
|
scroller.current?.scrollTo({ top: 0 });
|
|
}, [filterText]);
|
|
const [width, setWidth] = useState(() => Number(localStorage.getItem("pi-workspace-width")) || undefined);
|
|
// Room to split: the chat and the panel share what the sidebar leaves; the list keeps its width, and the chat and
|
|
// the file column split the rest equally (flex in style.css). Their sum doesn't change when the split toggles.
|
|
const column = useRef<HTMLDivElement>(null);
|
|
useLayoutEffect(() => {
|
|
const element = panel.current;
|
|
const main = element?.parentElement?.querySelector<HTMLElement>(":scope > .main");
|
|
const list = column.current;
|
|
if (!element || !main || !list) return;
|
|
const docked = window.matchMedia("(min-width: 1280px)");
|
|
const measure = () =>
|
|
setRoomy(docked.matches && main.offsetWidth + element.offsetWidth - list.offsetWidth >= 2 * SPLIT_MIN);
|
|
measure();
|
|
const observer = new ResizeObserver(measure);
|
|
for (const target of [element, main, list]) observer.observe(target);
|
|
docked.addEventListener("change", measure);
|
|
return () => {
|
|
observer.disconnect();
|
|
docked.removeEventListener("change", measure);
|
|
};
|
|
}, []);
|
|
// The handle captures its pointer, so a finger or mouse keeps resizing off the handle.
|
|
// During the drag only the CSS variable changes; React renders the panel once at the end.
|
|
const startResize = (event: React.PointerEvent<HTMLButtonElement>) => {
|
|
const element = panel.current;
|
|
if (!element || (event.pointerType === "mouse" && event.button !== 0)) return;
|
|
event.preventDefault();
|
|
const handle = event.currentTarget;
|
|
const pointer = event.pointerId;
|
|
handle.setPointerCapture(pointer);
|
|
const right = element.getBoundingClientRect().right;
|
|
let last = width;
|
|
const move = (moved: PointerEvent) => {
|
|
if (moved.pointerId !== pointer) return;
|
|
last = Math.round(Math.min(Math.max(right - moved.clientX, 320), window.innerWidth * 0.7));
|
|
element.style.setProperty("--workspace-width", `${last}px`);
|
|
};
|
|
const end = (ended: PointerEvent) => {
|
|
if (ended.pointerId !== pointer) return;
|
|
handle.removeEventListener("pointermove", move);
|
|
handle.removeEventListener("pointerup", end);
|
|
handle.removeEventListener("pointercancel", end);
|
|
if (!last) return;
|
|
setWidth(last);
|
|
localStorage.setItem("pi-workspace-width", String(last));
|
|
};
|
|
handle.addEventListener("pointermove", move);
|
|
handle.addEventListener("pointerup", end);
|
|
handle.addEventListener("pointercancel", end);
|
|
};
|
|
|
|
const rootName = cwd.replace(/\/+$/, "").split("/").pop() || "/";
|
|
|
|
const terminal = tab === "terminal" && owner && !selection;
|
|
// The open file: in place of the list, or beside it when split.
|
|
let fileBody: ReactNode;
|
|
if (selection) {
|
|
const name = selection.path.slice(selection.path.lastIndexOf("/") + 1);
|
|
const kind = filePreview(name)?.kind;
|
|
const deleted = selectedChange?.status.includes("D") ?? false;
|
|
// A commit's version has no working-tree URL, so no media preview or download.
|
|
const previewable =
|
|
!deleted &&
|
|
(kind === "image" || kind === "audio" || kind === "video" || /\.(md|markdown)$/i.test(name)) &&
|
|
!(at && kind !== undefined && kind !== "text");
|
|
const views: FileView[] = [
|
|
...(selectedChange ? (["changes"] as const) : []),
|
|
...(deleted ? [] : (["file"] as const)),
|
|
...(previewable ? (["preview"] as const) : []),
|
|
];
|
|
const current = (key: string, loaded: Loaded<unknown>) => (loaded.key === key ? loaded : undefined);
|
|
const fileLoaded = current(`file:${at ?? ""}:${selection.path}`, file) as typeof file | undefined;
|
|
const diffLoaded = diffMode
|
|
? (current(`diff:${diffMode}:${at ?? ""}:${selection.path}`, diff) as typeof diff | undefined)
|
|
: undefined;
|
|
fileBody = (
|
|
<WorkspaceFile
|
|
path={selection.path}
|
|
view={selection.view}
|
|
views={views}
|
|
file={needsFile ? fileLoaded?.value : undefined}
|
|
diff={diffMode ? diffLoaded?.value : undefined}
|
|
error={(needsFile ? fileLoaded?.error : undefined) ?? (diffMode ? diffLoaded?.error : undefined)}
|
|
url={at ? undefined : fileUrl(selection.path)}
|
|
commit={at}
|
|
wrap={wrap}
|
|
comments={comments}
|
|
editor={editor}
|
|
onEditor={setEditor}
|
|
line={selection.line}
|
|
contextLines={contextLines}
|
|
onContext={setContextLines}
|
|
onView={(view) =>
|
|
void nav.replace(update((current) => ({ ...current, file: { ...selection, view } }))(nav.route))
|
|
}
|
|
onWrap={() => {
|
|
localStorage.setItem("pi-workspace-wrap", wrap ? "0" : "1");
|
|
setWrap(!wrap);
|
|
}}
|
|
onBack={() => setSelection(undefined)}
|
|
closable={split}
|
|
onComments={setComments}
|
|
onSendOne={onSend && !sending ? (comment) => send([comment]) : undefined}
|
|
/>
|
|
);
|
|
}
|
|
let body: ReactNode;
|
|
if (!terminal && !attached && unavailable !== undefined) {
|
|
body = (
|
|
<>
|
|
<p className="notice error workspace-note">The workspace could not be opened: {unavailable}</p>
|
|
{onRetry && (
|
|
<button type="button" className="text-action workspace-note" onClick={onRetry}>
|
|
Retry
|
|
</button>
|
|
)}
|
|
</>
|
|
);
|
|
} else if (terminal) {
|
|
body = <Slot name="workspace-terminal" />;
|
|
} else if (selection && !split) {
|
|
body = fileBody;
|
|
} else if (tab === "files") {
|
|
const indent = (depth: number) => ({ paddingLeft: `calc(var(--x) + ${depth * 14}px)` });
|
|
const tree = (folder: string, depth: number): ReactNode[] => {
|
|
const loaded = listings[folder];
|
|
if (!loaded?.value)
|
|
return [
|
|
loaded?.error ? (
|
|
<p key={`${folder}\0error`} className="notice error workspace-note" style={indent(depth)}>
|
|
{loaded.error}
|
|
</p>
|
|
) : (
|
|
<div key={`${folder}\0loading`} className="workspace-row" style={indent(depth)}>
|
|
<span className="spinner" />
|
|
</div>
|
|
),
|
|
];
|
|
// Ignored entries show too, dimmed.
|
|
const entries = loaded.value.entries;
|
|
return [
|
|
...entries.flatMap((entry) => {
|
|
const path = folder ? `${folder}/${entry.name}` : entry.name;
|
|
const className = `workspace-row ${entry.ignored ? "ignored" : ""}`;
|
|
if (entry.kind === "directory") {
|
|
const open = expanded.has(path);
|
|
return [
|
|
<button
|
|
type="button"
|
|
key={path}
|
|
className={`${className} directory`}
|
|
style={indent(depth)}
|
|
aria-expanded={open}
|
|
onClick={() => toggleFolder(path)}
|
|
>
|
|
<span className={`chev ${open ? "open" : ""}`}>
|
|
<ChevronIcon size={12} />
|
|
</span>
|
|
<span className="workspace-name">{entry.name}</span>
|
|
</button>,
|
|
...(open ? tree(path, depth + 1) : []),
|
|
];
|
|
}
|
|
return [
|
|
<button
|
|
type="button"
|
|
key={path}
|
|
className={className}
|
|
style={indent(depth)}
|
|
aria-current={split && selection?.path === path ? "true" : undefined}
|
|
onClick={() => setSelection({ path, view: initialView(path) })}
|
|
>
|
|
<FileIcon />
|
|
<span className="workspace-name">{entry.name}</span>
|
|
<small>{entry.kind === "symlink" ? "link" : size(entry.size)}</small>
|
|
</button>,
|
|
];
|
|
}),
|
|
...(!entries.length
|
|
? [
|
|
<p key={`${folder}\0empty`} className="notice workspace-note" style={indent(depth)}>
|
|
{folder ? "Empty folder." : "Empty directory."}
|
|
</p>,
|
|
]
|
|
: []),
|
|
...(loaded.value.truncated
|
|
? [
|
|
<p key={`${folder}\0truncated`} className="notice workspace-note" style={indent(depth)}>
|
|
Showing the first 2,000 entries.
|
|
</p>,
|
|
]
|
|
: []),
|
|
];
|
|
};
|
|
body = (
|
|
<>
|
|
<div className="workspace-filter">
|
|
<div className="search workspace-search">
|
|
<SearchIcon />
|
|
<input
|
|
type="search"
|
|
value={fileFilter}
|
|
maxLength={200}
|
|
autoCapitalize="off"
|
|
autoCorrect="off"
|
|
spellCheck={false}
|
|
onChange={(event) => setFileFilter(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
// As in Quick Open: Enter opens the best match, Escape clears.
|
|
const best = ranked[0];
|
|
if (event.key === "Enter" && best)
|
|
setSelection({ path: best.path, view: initialView(best.path) });
|
|
else if (event.key === "Escape" && fileFilter) {
|
|
event.stopPropagation();
|
|
setFileFilter("");
|
|
}
|
|
}}
|
|
placeholder="Go to file"
|
|
aria-label="Go to file by name"
|
|
/>
|
|
{fileFilter && (
|
|
<button type="button" className="text-action" onClick={() => setFileFilter("")}>
|
|
Clear
|
|
</button>
|
|
)}
|
|
</div>
|
|
<label
|
|
className="text-action workspace-upload"
|
|
title={`Upload into ${directory ? `${rootName}/${directory}` : rootName}`}
|
|
>
|
|
{uploading ? `Uploading ${uploading}…` : `Upload to ${directory.split("/").pop() || rootName}`}
|
|
<input
|
|
type="file"
|
|
multiple
|
|
hidden
|
|
disabled={uploading !== undefined}
|
|
onChange={(event) => {
|
|
void upload([...(event.target.files ?? [])]);
|
|
event.target.value = "";
|
|
}}
|
|
/>
|
|
</label>
|
|
</div>
|
|
{uploadError && <p className="notice error workspace-note">{uploadError}</p>}
|
|
{filterText ? (
|
|
fileIndex.error ? (
|
|
<p className="notice error workspace-note">{fileIndex.error}</p>
|
|
) : !filePaths ? (
|
|
<div className="spinner large" />
|
|
) : (
|
|
<div className="workspace-list">
|
|
{ranked.map(({ path, score }) => {
|
|
const slash = path.lastIndexOf("/");
|
|
return (
|
|
<button
|
|
type="button"
|
|
key={path}
|
|
className="workspace-row"
|
|
aria-current={split && selection?.path === path ? "true" : undefined}
|
|
onClick={() => setSelection({ path, view: initialView(path) })}
|
|
>
|
|
<FileIcon />
|
|
<span className="workspace-name">
|
|
<Highlight text={path.slice(slash + 1)} matches={score.labelMatch} />
|
|
{slash > 0 && (
|
|
<span className="muted">
|
|
{" "}
|
|
<Highlight text={path.slice(0, slash)} matches={score.descriptionMatch} />
|
|
</span>
|
|
)}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
{!ranked.length && <p className="notice workspace-note">No matching files.</p>}
|
|
{fileIndex.value?.truncated && (
|
|
<p className="notice workspace-note">Only the first 50,000 files are searched.</p>
|
|
)}
|
|
</div>
|
|
)
|
|
) : (
|
|
<div className="workspace-list workspace-tree">{tree("", 0)}</div>
|
|
)}
|
|
</>
|
|
);
|
|
} else if (tab === "comments") {
|
|
const ordered = comments.toSorted((a, b) => a.path.localeCompare(b.path) || a.end - b.end);
|
|
body = ordered.length ? (
|
|
<div className="workspace-list">
|
|
{ordered.map((comment) => (
|
|
<div key={comment.id} className="comment-row">
|
|
<button
|
|
type="button"
|
|
className="workspace-row"
|
|
onClick={() =>
|
|
// One step to the file (and its commit); Back returns to this list.
|
|
step({
|
|
file: {
|
|
path: comment.path,
|
|
view: comment.view,
|
|
line: comment.end,
|
|
...(comment.commit ? { commit: comment.commit } : {}),
|
|
},
|
|
commit: comment.commit,
|
|
})
|
|
}
|
|
>
|
|
<span className="workspace-name">
|
|
<span className="muted">
|
|
{comment.path}:{comment.end}
|
|
{comment.commit ? ` @${comment.commit.slice(0, 8)}` : ""}
|
|
{comment.view === "changes" ? " (diff)" : ""}
|
|
</span>
|
|
<br />
|
|
<span className="comment-quote">{comment.quote}</span>
|
|
<br />
|
|
<span className="comment-text">{comment.text}</span>
|
|
</span>
|
|
</button>
|
|
<span className="comment-actions">
|
|
{onSend && (
|
|
<button
|
|
type="button"
|
|
className="text-action"
|
|
disabled={sending}
|
|
onClick={() => send([comment])}
|
|
>
|
|
Send
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="icon-button"
|
|
aria-label="Delete comment"
|
|
onClick={() => setComments(comments.filter((entry) => entry.id !== comment.id))}
|
|
>
|
|
<CloseIcon />
|
|
</button>
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="notice workspace-note">No unsent comments. Tap a line number in a file or diff to comment.</p>
|
|
);
|
|
} else if (tab === "search") {
|
|
const value = search.value;
|
|
const filtered = Boolean(include.trim() || exclude.trim());
|
|
const glob = (label: string, text: string, set: (value: string) => void, example: string) => (
|
|
<label className="search workspace-search workspace-glob">
|
|
<span>{label}</span>
|
|
<input
|
|
value={text}
|
|
maxLength={500}
|
|
autoCapitalize="off"
|
|
autoCorrect="off"
|
|
spellCheck={false}
|
|
onChange={(event) => set(event.target.value)}
|
|
placeholder={example}
|
|
aria-label={`Files to ${label}`}
|
|
/>
|
|
</label>
|
|
);
|
|
body = (
|
|
<>
|
|
<form
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
if (searchText.trim()) setSubmitted({ text: searchText, include, exclude });
|
|
}}
|
|
>
|
|
<div className="search workspace-search">
|
|
<SearchIcon />
|
|
<input
|
|
type="search"
|
|
value={searchText}
|
|
maxLength={200}
|
|
onChange={(event) => setSearchText(event.target.value)}
|
|
placeholder="Search text in files"
|
|
aria-label="Search text in files"
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="text-action"
|
|
aria-expanded={filters}
|
|
onClick={() => setFilters(!filters)}
|
|
>
|
|
{filters || !filtered ? "Filters" : "Filters on"}
|
|
</button>
|
|
</div>
|
|
{filters && (
|
|
<>
|
|
{glob("include", include, setInclude, "*.ts, src/")}
|
|
{glob("exclude", exclude, setExclude, "*.test.ts, dist")}
|
|
</>
|
|
)}
|
|
{/* Enter in any field submits the form. */}
|
|
<button type="submit" hidden />
|
|
</form>
|
|
{search.error ? (
|
|
<p className="notice error workspace-note">{search.error}</p>
|
|
) : !submitted ? (
|
|
<p className="notice workspace-note">
|
|
Literal text; case-insensitive unless it has capitals. Ignored files are skipped.
|
|
{filters &&
|
|
" Filters take comma-separated globs such as *.ts, *.{ts,tsx} or src/, matching at any depth unless they start with ./."}
|
|
</p>
|
|
) : !value || search.key !== searchKey ? (
|
|
<div className="spinner large" />
|
|
) : (
|
|
<div className="workspace-list">
|
|
{value.matches.map((match, index) => (
|
|
<button
|
|
type="button"
|
|
key={`${match.path}:${match.line}:${index}`}
|
|
className="workspace-row match"
|
|
aria-current={
|
|
split && selection?.path === match.path && selection.line === match.line ? "true" : undefined
|
|
}
|
|
onClick={() => setSelection({ path: match.path, view: "file", line: match.line })}
|
|
>
|
|
<span className="workspace-name">
|
|
<span className="muted">
|
|
{match.path}:{match.line}
|
|
</span>
|
|
<br />
|
|
{match.text.trim()}
|
|
</span>
|
|
</button>
|
|
))}
|
|
{!value.matches.length && <p className="notice workspace-note">No matches.</p>}
|
|
{value.truncated && <p className="notice workspace-note">Showing the first matches.</p>}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
} else {
|
|
const value = status.value;
|
|
const render = (nodes: ChangeNode[], depth: number, commit?: string): ReactNode[] =>
|
|
nodes.flatMap((node) => {
|
|
const indent = { paddingLeft: `calc(var(--x) + ${depth * 14}px)` };
|
|
if (node.change) {
|
|
const change = node.change;
|
|
return [
|
|
<button
|
|
type="button"
|
|
key={node.path}
|
|
className="workspace-row"
|
|
style={indent}
|
|
title={`${describe(change.status)}${change.from ? ` from ${change.from}` : ""}`}
|
|
aria-current={
|
|
split && selection?.path === change.path && selection.commit === commit ? "true" : undefined
|
|
}
|
|
onClick={() => setSelection({ path: change.path, view: "changes", ...(commit ? { commit } : {}) })}
|
|
>
|
|
<span className={`workspace-status status-${letter(change.status)}`}>{letter(change.status)}</span>
|
|
<span className="workspace-name">{node.name}</span>
|
|
{(() => {
|
|
const count = comments.filter(
|
|
(comment) => comment.path === change.path && comment.commit === commit,
|
|
).length;
|
|
return count ? (
|
|
<small className="comment-count" title="Has review comments">
|
|
{count} comment{count === 1 ? "" : "s"}
|
|
</small>
|
|
) : null;
|
|
})()}
|
|
</button>,
|
|
];
|
|
}
|
|
const open = !collapsed.has(node.path);
|
|
return [
|
|
<button
|
|
type="button"
|
|
key={node.path}
|
|
className="workspace-row directory"
|
|
style={indent}
|
|
aria-expanded={open}
|
|
onClick={() =>
|
|
setCollapsed((current) => {
|
|
const next = new Set(current);
|
|
if (open) next.add(node.path);
|
|
else next.delete(node.path);
|
|
return next;
|
|
})
|
|
}
|
|
>
|
|
<span className={`chev ${open ? "open" : ""}`}>
|
|
<ChevronIcon size={12} />
|
|
</span>
|
|
<span className="workspace-name">{node.name}</span>
|
|
</button>,
|
|
...(open ? render(node.children, depth + 1, commit) : []),
|
|
];
|
|
});
|
|
const detail = commitDetail.key === `commit:${openCommit}` ? commitDetail : undefined;
|
|
const summary = log.commits.find((entry) => entry.hash === openCommit);
|
|
body = openCommit ? (
|
|
<div className="workspace-view">
|
|
<div className="workspace-bars">
|
|
<div className="workspace-bar">
|
|
<button
|
|
type="button"
|
|
className="icon-button back"
|
|
onClick={() => setOpenCommit(undefined)}
|
|
aria-label="Back"
|
|
>
|
|
<ChevronIcon size={18} />
|
|
</button>
|
|
<span className="workspace-path">{summary?.subject ?? openCommit.slice(0, 8)}</span>
|
|
<span className="workspace-commit mono">{openCommit.slice(0, 8)}</span>
|
|
</div>
|
|
</div>
|
|
{detail?.error ? (
|
|
<p className="notice error workspace-note">{detail.error}</p>
|
|
) : !detail?.value ? (
|
|
<div className="spinner large" />
|
|
) : (
|
|
<>
|
|
<div className="commit-meta">
|
|
<span className="muted">
|
|
{detail.value.author} · {new Date(detail.value.time).toLocaleString()}
|
|
{summary?.unpushed ? " · not pushed" : ""}
|
|
</span>
|
|
{detail.value.message.includes("\n") && (
|
|
<pre className="commit-message">
|
|
{detail.value.message.split("\n").slice(1).join("\n").trim()}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
<div className="workspace-list">
|
|
{render(changeTree(detail.value.changes), 0, openCommit)}
|
|
{!detail.value.changes.length && <p className="notice workspace-note">No file changes.</p>}
|
|
{detail.value.truncated && <p className="notice workspace-note">Change list truncated.</p>}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
) : status.error ? (
|
|
<p className="notice error workspace-note">{status.error}</p>
|
|
) : !value ? (
|
|
<div className="spinner large" />
|
|
) : !value.repo ? (
|
|
<p className="notice workspace-note">Not a git repository.</p>
|
|
) : (
|
|
<>
|
|
<p className="workspace-crumbs mono muted">
|
|
{value.branch ?? "detached"}
|
|
{log.upstream ? ` → ${log.upstream}` : ""}
|
|
</p>
|
|
{value.changes.length > 0 && (
|
|
<>
|
|
<h3 className="workspace-section">Uncommitted</h3>
|
|
<div className="workspace-list">
|
|
{render(changeTree(value.changes), 0)}
|
|
{value.truncated && <p className="notice workspace-note">Change list truncated.</p>}
|
|
</div>
|
|
</>
|
|
)}
|
|
<h3 className="workspace-section">Commits</h3>
|
|
<div className="workspace-list">
|
|
{log.commits.map((entry) => (
|
|
<button
|
|
type="button"
|
|
key={entry.hash}
|
|
className={`workspace-row commit-row ${entry.unpushed ? "unpushed" : ""}`}
|
|
title={entry.unpushed ? "Not pushed" : undefined}
|
|
onClick={() => setOpenCommit(entry.hash)}
|
|
>
|
|
<span className="commit-dot" />
|
|
<span className="workspace-name">
|
|
{entry.subject}
|
|
<br />
|
|
<span className="muted">
|
|
{entry.short} · {entry.author} · {ago(entry.time)}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
))}
|
|
{log.error && <p className="notice error workspace-note">{log.error}</p>}
|
|
{!log.commits.length && !log.error && <p className="notice workspace-note">No commits.</p>}
|
|
{log.more && (
|
|
<button type="button" className="text-action workspace-note" onClick={loadMore}>
|
|
Load more commits
|
|
</button>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<aside
|
|
className={`workspace ${split ? "split" : ""}`}
|
|
aria-label="Workspace"
|
|
ref={panel}
|
|
style={width ? ({ "--workspace-width": `${width}px` } as CSSProperties) : undefined}
|
|
>
|
|
{/* The column stays the second child either way, so toggling the split never remounts the list. */}
|
|
{split && (
|
|
<section className="workspace-preview" aria-label="Open file">
|
|
<div className="workspace-preview-body" ref={previewScroller}>
|
|
{fileBody}
|
|
</div>
|
|
</section>
|
|
)}
|
|
<div className="workspace-column" ref={column}>
|
|
<button
|
|
type="button"
|
|
className="workspace-resize"
|
|
aria-label="Drag to resize workspace"
|
|
tabIndex={-1}
|
|
onPointerDown={startResize}
|
|
/>
|
|
<header className="workspace-head">
|
|
<div className="workspace-tabs">
|
|
{(
|
|
[
|
|
["files", "Files", <FolderIcon key="icon" size={20} />, 0],
|
|
["changes", "Changes", <DiffIcon key="icon" />, changes.length],
|
|
["search", "Search", <SearchIcon key="icon" size={20} />, 0],
|
|
["comments", "Comments", <CommentIcon key="icon" />, comments.length],
|
|
...(owner ? [["terminal", "Terminal", <TerminalIcon key="icon" size={20} />, 0] as const] : []),
|
|
] as const
|
|
).map(([id, label, icon, count]) => (
|
|
<button
|
|
key={id}
|
|
type="button"
|
|
aria-pressed={tab === id}
|
|
aria-label={count ? `${label} (${count})` : label}
|
|
title={label}
|
|
onClick={() => setTab(id)}
|
|
>
|
|
{icon}
|
|
{count > 0 && <span>{count}</span>}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<span className="spacer workspace-location" title={shownWhere}>
|
|
{shownWhere}
|
|
</span>
|
|
{!terminal && (
|
|
<button
|
|
type="button"
|
|
className="icon-button"
|
|
onClick={() => setVersion((current) => current + 1)}
|
|
aria-label="Refresh"
|
|
title="Refresh"
|
|
>
|
|
<RefreshIcon />
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="icon-button"
|
|
onClick={() => void nav.close((current) => ({ ...current, workspace: undefined }))}
|
|
aria-label="Close workspace"
|
|
>
|
|
<CloseIcon />
|
|
</button>
|
|
</header>
|
|
<div className={`workspace-body ${terminal ? "terminal-body" : ""}`} ref={scroller}>
|
|
{moveNote && !terminal && <p className="notice workspace-note">{moveNote}</p>}
|
|
{body}
|
|
</div>
|
|
{comments.length > 0 && (
|
|
<div className="review-bar">
|
|
<span>
|
|
{comments.length} comment{comments.length === 1 ? "" : "s"}
|
|
{commented.size > 1 ? ` in ${commented.size} files` : ""}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="text-action"
|
|
onClick={() => {
|
|
setComments([]);
|
|
setEditor(undefined);
|
|
}}
|
|
>
|
|
Discard
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="button primary"
|
|
disabled={!onSend || sending}
|
|
onClick={() => send(comments)}
|
|
>
|
|
Send all
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|