mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
6bab6fbe97
Documents the agent writes open in the workspace panel instead of another application, and local images the agent mentions show up in the conversation. Workspace preview - PDF (pdf.js with its own layout and text layer), Word (docx-preview inside a scripts-disabled sandboxed iframe) and Excel (SheetJS; .xlsx, .xlsm, .xls) open in the side panel with zoom and fit, per-file scroll/zoom/sheet memory, and a refresh when the agent rewrites the file. The engines load lazily. - Bytes come from a new GET /api/sessions/:id/workspace/raw route, with an extension allowlist, size caps, the workspace boundary and canonical-path checks. The file endpoint returns metadata and a version for documents. The client fetches with the bearer credential, so it works in Electron, LAN H5 and remote access alike. - Chat links, output cards and the change card open pdf/docx/xlsx in the workspace; documents outside the workdir still go to the system application. - Image viewer with fit, zoom and pan, and "open in system app". Chat images - Markdown images outside the workdir, at ~/, C:\ and file:// paths render, open in the viewer, and offer "open original" (pictures only). - Images returned by tools such as Read appear as thumbnails under the call. Hardening found in review - previewFsUrl escapes each path segment; a double-escaped %2e%2e used to leave /preview-fs/<session>/. - The CORS, API timing and remote-access header decorators set headers in place. Rebuilding the response buffered whole files in memory and dropped Content-Length. - The engine owns the pdf.js worker, so closing one document no longer fails the next open. - Office archives are inflated in steps to check their real sizes, not the sizes they declare. - A viewer that fails to load stays in its panel instead of taking the window down. Adds pdfjs-dist, docx-preview, xlsx (SheetJS 0.20.3 tarball) and fflate as renderer dev dependencies; Vite bundles them. Refs #1397
112 lines
3.5 KiB
TypeScript
112 lines
3.5 KiB
TypeScript
import { Maximize2, Minus, Plus } from 'lucide-react'
|
||
|
||
import { cx } from '@/lib/cx'
|
||
import { IconButton } from './IconButton'
|
||
|
||
export type ZoomControlsLabels = {
|
||
/** Names the whole cluster ("Zoom controls"). */
|
||
group: string
|
||
zoomIn: string
|
||
zoomOut: string
|
||
/** What the fit button does for this viewer: "Fit to window" or "Fit to width". */
|
||
fit: string
|
||
}
|
||
|
||
/**
|
||
* The look of a control floating over a viewer: a hairline-bordered pill. Shared
|
||
* so an extra action placed beside the zoom cluster reads as part of it.
|
||
*/
|
||
export function floatingPillClass(surface: 'default' | 'media', { flat = false }: { flat?: boolean } = {}): string {
|
||
return cx(
|
||
'inline-flex items-center gap-0.5 rounded-[var(--radius-lg)] border p-0.5',
|
||
// A pill floating over content lifts off it with a shadow; one sitting in a
|
||
// toolbar is already on a surface of its own.
|
||
!flat && 'shadow-[var(--shadow-card)]',
|
||
surface === 'media'
|
||
? 'border-[var(--color-media-border)] bg-[var(--color-media-header)] text-[var(--color-media-muted)]'
|
||
: 'border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] text-[var(--color-text-secondary)]',
|
||
)
|
||
}
|
||
|
||
export type ZoomControlsProps = {
|
||
/** The zoom as the whole-number percentage a person reads. */
|
||
percent: number
|
||
/** The viewer is showing whatever scale fits, rather than a scale the user chose. */
|
||
fitActive: boolean
|
||
canZoomIn: boolean
|
||
canZoomOut: boolean
|
||
onZoomIn: () => void
|
||
onZoomOut: () => void
|
||
onFit: () => void
|
||
/** Caller-supplied so this primitive never carries user-visible text of its own. */
|
||
labels: ZoomControlsLabels
|
||
/**
|
||
* `media` for a control that floats over the dark lightbox, whose colors do
|
||
* not follow the theme; `default` for one that sits on a panel.
|
||
*/
|
||
surface?: 'default' | 'media'
|
||
/** No drop shadow: for a cluster that sits in a toolbar rather than floating over content. */
|
||
flat?: boolean
|
||
className?: string
|
||
}
|
||
|
||
/**
|
||
* The zoom cluster shared by every viewer: − / percentage / + / fit.
|
||
*
|
||
* The percentage is a readout, not a button. "Actual size" is reached by the "+"
|
||
* ladder passing through 100%, by a double click on the content, and by the
|
||
* keyboard, so it does not need a control of its own competing for room in a
|
||
* narrow panel.
|
||
*/
|
||
export function ZoomControls({
|
||
percent,
|
||
fitActive,
|
||
canZoomIn,
|
||
canZoomOut,
|
||
onZoomIn,
|
||
onZoomOut,
|
||
onFit,
|
||
labels,
|
||
surface = 'default',
|
||
flat = false,
|
||
className,
|
||
}: ZoomControlsProps) {
|
||
const media = surface === 'media'
|
||
return (
|
||
<div
|
||
role="group"
|
||
aria-label={labels.group}
|
||
className={cx(floatingPillClass(surface, { flat }), className)}
|
||
>
|
||
<IconButton
|
||
icon={<Minus size={16} strokeWidth={1.9} />}
|
||
label={labels.zoomOut}
|
||
size="md"
|
||
tone="secondary"
|
||
surface={media ? 'media' : 'default'}
|
||
disabled={!canZoomOut}
|
||
onClick={onZoomOut}
|
||
/>
|
||
<span className="min-w-[3.25rem] px-1 text-center font-mono text-xs tabular-nums">{percent}%</span>
|
||
<IconButton
|
||
icon={<Plus size={16} strokeWidth={1.9} />}
|
||
label={labels.zoomIn}
|
||
size="md"
|
||
tone="secondary"
|
||
surface={media ? 'media' : 'default'}
|
||
disabled={!canZoomIn}
|
||
onClick={onZoomIn}
|
||
/>
|
||
<IconButton
|
||
icon={<Maximize2 size={15} strokeWidth={1.9} />}
|
||
label={labels.fit}
|
||
size="md"
|
||
tone="secondary"
|
||
surface={media ? 'media' : 'default'}
|
||
pressed={fitActive}
|
||
onClick={onFit}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|