5530b7ffad
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
118 lines
5.7 KiB
TypeScript
118 lines
5.7 KiB
TypeScript
/**
|
|
* Files panel: on desktop (≥1024px, see isDocked in use-files-panel.ts) it docks to the right
|
|
* of the chat with a drag-to-resize edge; on narrower viewports it becomes a bottom Sheet
|
|
* (snaps to half for browsing / full for preview, gesture-draggable) so the vertical layout
|
|
* keeps the chat transcript above it visible. Content is a single WorkspaceBrowser directory-tree
|
|
* view; clicking a file chip in a message navigates the tree via openRequest (use-files-panel.ts).
|
|
*/
|
|
import type { SessionInfo } from "@prismshadow/penguin-server/api";
|
|
import { S } from "../../lib/strings";
|
|
import { Sheet } from "../../components/ui/sheet";
|
|
import { WorkspaceBrowser } from "./workspace-browser";
|
|
import type { FilesPanelState } from "./use-files-panel";
|
|
|
|
function CloseIcon() {
|
|
return (
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" aria-hidden>
|
|
<path d="M2 2l10 10M12 2L2 12" strokeWidth="1.5" strokeLinecap="round" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
export function FilesPanel({ session, panel }: { session: SessionInfo; panel: FilesPanelState }) {
|
|
if (!panel.isDocked) {
|
|
return (
|
|
<Sheet
|
|
open={panel.open}
|
|
snap={panel.sheetSnap}
|
|
onSnapChange={panel.setSheetSnap}
|
|
onClose={() => panel.setOpen(false)}
|
|
title={S.files.title}
|
|
>
|
|
<div className="flex h-full min-h-0 flex-col">
|
|
<div className="min-h-0 flex-1">
|
|
<WorkspaceBrowser
|
|
session={session}
|
|
openRequest={panel.openRequest}
|
|
active={panel.open}
|
|
// Entering preview from list view: bump the snap point up to full (preview needs the space)
|
|
onPreviewOpen={() => panel.setSheetSnap("full")}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Sheet>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{panel.open && (
|
|
<div
|
|
onMouseDown={panel.startResize}
|
|
onDoubleClick={panel.resetWidth}
|
|
title={S.files.resizeHandle}
|
|
className={`w-1.5 shrink-0 cursor-col-resize transition-colors duration-150 hover:bg-brand-300/50 dark:hover:bg-brand-700/40 ${
|
|
panel.resizing ? "bg-brand-400/60" : "bg-transparent"
|
|
}`}
|
|
/>
|
|
)}
|
|
<div
|
|
ref={panel.panelRef}
|
|
style={{ width: panel.open ? panel.width : 0 }}
|
|
// Use inert rather than unmounting when closed: the width transition needs the node to
|
|
// stay mounted, and inert removes content collapsed to 0 width from the tab order and
|
|
// accessibility tree, so keyboard users can't Tab into a close button that's visually gone.
|
|
inert={!panel.open}
|
|
// Freeze the panel's pointer events while dragging to resize: a preview iframe
|
|
// (HTML/PDF) is a separate document, and mousemove over it gets swallowed instead of
|
|
// reaching us, so the width stops tracking the cursor; pointer-events-none lets events
|
|
// pass through.
|
|
//
|
|
// relative: the clipping window acts as its own containing block. Content is fixed at
|
|
// the target width (see below), and when closed the whole block sits outside the
|
|
// viewport's right edge — if an absolute descendant (e.g. the upload button's sr-only
|
|
// input) anchored to the nearest initial containing block instead, it would bypass this
|
|
// overflow-hidden and stretch the **document** wide, making a horizontal scrollbar
|
|
// appear out of nowhere.
|
|
//
|
|
// The divider belongs to the OPEN state only: with border-box sizing a closed panel's
|
|
// border still paints its 1px even at width 0, and since both docked panels stay
|
|
// mounted, the closed one left a stray line beside the open one — a hairline, a gap
|
|
// (the open panel's resize handle) and then the real divider, which reads as an extra
|
|
// empty panel wedged in. Both closed, the two leftover lines stacked at the window edge.
|
|
className={`relative flex min-h-0 shrink-0 flex-col overflow-hidden ${
|
|
panel.open ? "border-l border-gray-200 dark:border-gray-800" : ""
|
|
} ${panel.resizing ? "pointer-events-none" : "transition-[width] duration-200"}`}
|
|
>
|
|
{/* Content is fixed at the target width; the outer element is only a clipping window:
|
|
during the open/close animation the outer element passes through intermediate
|
|
widths, and if the content resized along with it, the text would get squeezed into
|
|
a column frame-by-frame before expanding back out. With a fixed width, the content
|
|
behaves as a rigid body that slides in and out past the clipping edge with zero
|
|
reflow. While dragging to resize, both values stay in sync, so this isn't affected. */}
|
|
<div style={{ width: panel.width }} className="flex h-full min-h-0 flex-col">
|
|
{/* Title row for docked state (the Sheet state has its own title bar via Sheet, no duplication needed) */}
|
|
<div className="flex shrink-0 items-center gap-1 px-3 pt-2">
|
|
<h4 className="min-w-0 flex-1 truncate text-sm font-semibold">{S.files.title}</h4>
|
|
<button
|
|
type="button"
|
|
onClick={() => panel.setOpen(false)}
|
|
title={S.common.close}
|
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-gray-400 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-700 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
|
>
|
|
<CloseIcon />
|
|
</button>
|
|
</div>
|
|
<div className="min-h-0 flex-1">
|
|
<WorkspaceBrowser
|
|
session={session}
|
|
openRequest={panel.openRequest}
|
|
active={panel.open}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|