mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
feat(desktop): per-format file icons, and fixes for workspace open, web images and xlsx widths (#1419)
* feat(desktop): show per-format file icons on chat file cards Generated-file cards, the turn change card and message attachments used one grey Material glyph for every file. They now render a folded-corner document icon with a colored body and the extension label (PDF red, Word blue, Markdown blue, Excel green, PowerPoint orange, archives amber, code purple, ...), built on react-file-icon instead of hand-drawn art. Extensions the library does not know are mapped to a close sibling or to a category color, and jsonl now counts as code. Brand colors live in lib/fileTypePalette.ts because react-file-icon writes them into SVG attributes, where CSS variables are not reliable. * fix(desktop): open workspace documents written through a symlinked path Clicking an output card for /tmp/app/report.pdf opened the system app instead of the workspace preview when the session's canonical workdir is /private/tmp/app. The gate compared path strings, so a symlinked form, a workdir that had not loaded yet, or a registered access root all looked like "outside the workspace". The string check stays as the fast path. When it says "outside", ask the server through getWorkspaceFile, which resolves real paths: an accepted document opens in the workspace, a 403 still goes to the system app. * fix(desktop): load local images in the web UI and H5 A bare <img src> cannot send Authorization, and the server refuses a credential-less cross-site subresource load, so the browser blocked the response (net::ERR_BLOCKED_BY_ORB) and chat showed "unable to load image". Only the Electron shell worked, because its main process injects the credential for an allowlist of media routes. When an <img> fails, retry once through the credentialed client and show the result as a blob: URL; the failure notice appears only if that also fails. Covers the inline image gallery, the lightbox, image generation slots and Markdown images. The credential is only ever sent to the local server's own origin. * fix(desktop): keep spreadsheet numbers whole in the workspace preview Columns without a stored width fell back to a fixed 72px, and stored widths were chosen for Excel's font, which is narrower than the preview's. Values such as 12,000.00 were clipped to "12,000...". A column the file gives no width now fits its widest cell, and a number column never ends up narrower than its numbers. Text in a column whose width the file sets is still clipped, as Excel clips it, and merged headings that spill over their span do not widen a column.
This commit is contained in:
@@ -33,6 +33,7 @@
|
||||
"react": "^18.3.1",
|
||||
"react-diff-viewer-continued": "^4.2.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-file-icon": "^1.6.0",
|
||||
"react-shiki": "^0.9.2",
|
||||
"sharp": "0.34.5",
|
||||
"shiki": "^4.0.2",
|
||||
@@ -46,6 +47,7 @@
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/react-file-icon": "^1.0.5",
|
||||
"@typescript-eslint/parser": "^8",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
@@ -644,6 +646,8 @@
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@18.3.7", "https://registry.npmmirror.com/@types/react-dom/-/react-dom-18.3.7.tgz", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
|
||||
|
||||
"@types/react-file-icon": ["@types/react-file-icon@1.0.5", "https://registry.npmmirror.com/@types/react-file-icon/-/react-file-icon-1.0.5.tgz", { "dependencies": { "@types/react": "*" } }, "sha512-2QkghuxsQhwJ7J1QdkFxMEciPyh/H/fX/ShAHAje1iuwcOnCI3AwnMZ3LHYvNv/3XYArV5KCbMHOtqiHIYJg0Q=="],
|
||||
|
||||
"@types/responselike": ["@types/responselike@1.0.3", "https://registry.npmmirror.com/@types/responselike/-/responselike-1.0.3.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="],
|
||||
|
||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||
@@ -808,6 +812,8 @@
|
||||
|
||||
"color-name": ["color-name@1.1.4", "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"colord": ["colord@2.10.0", "https://registry.npmmirror.com/colord/-/colord-2.10.0.tgz", {}, "sha512-AidJptpBJmjTclAp9BkLwJi0T93fo5epJnbaZslpg6QVzpHjAiveF55mE9AcUJiGMqRHgMDY8soMsQtuNYMHfw=="],
|
||||
|
||||
"combined-stream": ["combined-stream@1.0.8", "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||
|
||||
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "https://registry.npmmirror.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
|
||||
@@ -1420,6 +1426,8 @@
|
||||
|
||||
"nwsapi": ["nwsapi@2.2.23", "https://registry.npmmirror.com/nwsapi/-/nwsapi-2.2.23.tgz", {}, "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"object-keys": ["object-keys@1.1.1", "https://registry.npmmirror.com/object-keys/-/object-keys-1.1.1.tgz", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
|
||||
|
||||
"once": ["once@1.4.0", "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
@@ -1510,6 +1518,8 @@
|
||||
|
||||
"promise-retry": ["promise-retry@2.0.1", "https://registry.npmmirror.com/promise-retry/-/promise-retry-2.0.1.tgz", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="],
|
||||
|
||||
"prop-types": ["prop-types@15.8.1", "https://registry.npmmirror.com/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
||||
|
||||
"proper-lockfile": ["proper-lockfile@4.1.2", "https://registry.npmmirror.com/proper-lockfile/-/proper-lockfile-4.1.2.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="],
|
||||
|
||||
"property-information": ["property-information@7.1.0", "https://registry.npmmirror.com/property-information/-/property-information-7.1.0.tgz", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
|
||||
@@ -1542,6 +1552,8 @@
|
||||
|
||||
"react-dom": ["react-dom@18.3.1", "https://registry.npmmirror.com/react-dom/-/react-dom-18.3.1.tgz", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
|
||||
|
||||
"react-file-icon": ["react-file-icon@1.6.0", "https://registry.npmmirror.com/react-file-icon/-/react-file-icon-1.6.0.tgz", { "dependencies": { "colord": "^2.9.3", "prop-types": "^15.7.2" }, "peerDependencies": { "react": "^19.0.0 || ^18.0.0 || ^17.0.0 || ^16.2.0", "react-dom": "^19.0.0 || ^18.0.0 || ^17.0.0 || ^16.2.0" } }, "sha512-Ba4Qa2ya/kvhcCd4LJja77sV7JD7u1ZXcI1DUz+TII3nGmglG6QY+NZeHizThokgct3qI0glwb9eV8NqRGs5lw=="],
|
||||
|
||||
"react-is": ["react-is@17.0.2", "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
|
||||
|
||||
"react-shiki": ["react-shiki@0.9.3", "https://registry.npmmirror.com/react-shiki/-/react-shiki-0.9.3.tgz", { "dependencies": { "clsx": "^2.1.1", "dequal": "^2.0.3", "hast-util-to-jsx-runtime": "^2.3.6", "shiki": "^4.0.0", "unist-util-visit": "^5.1.0" }, "peerDependencies": { "@types/react": ">=16.8.0", "@types/react-dom": ">=16.8.0", "react": ">= 16.8.0", "react-dom": ">= 16.8.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F2Uju1/BeUTFQeS+3v3HM0Ry4p+8gcLC4ssObmXxwrzlwPJYq5RGAKcA1r5JBEnJCpEVKf9PajnwM+JMwZnzGg=="],
|
||||
@@ -1944,6 +1956,8 @@
|
||||
|
||||
"postject/commander": ["commander@9.5.0", "https://registry.npmmirror.com/commander/-/commander-9.5.0.tgz", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
|
||||
|
||||
"prop-types/react-is": ["react-is@16.13.1", "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
|
||||
"qrcode/yargs": ["yargs@15.4.1", "https://registry.npmmirror.com/yargs/-/yargs-15.4.1.tgz", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
|
||||
|
||||
"rimraf/glob": ["glob@7.2.3", "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||
|
||||
@@ -138,6 +138,7 @@
|
||||
"react": "^18.3.1",
|
||||
"react-diff-viewer-continued": "^4.2.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-file-icon": "^1.6.0",
|
||||
"react-shiki": "^0.9.2",
|
||||
"sharp": "0.34.5",
|
||||
"shiki": "^4.0.2",
|
||||
@@ -151,6 +152,7 @@
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/react-file-icon": "^1.0.5",
|
||||
"@typescript-eslint/parser": "^8",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { IconButton } from '@/components/ui/IconButton'
|
||||
import { OpenWithMenu } from '@/components/composite/OpenWithMenu'
|
||||
import { FileTypeIcon } from '@/components/ui/FileTypeIcon'
|
||||
import { describeFileType, type OpenWithItem } from '../../lib/openWithItems'
|
||||
import { buildOpenWithMenuItemsForHref } from '../../lib/openWithMenuItems'
|
||||
import { openPreviewLink } from '../../lib/openPreviewLink'
|
||||
@@ -23,7 +24,6 @@ export function AssistantOutputTargetCard({ target, sessionId, workDir }: Props)
|
||||
|
||||
const isLocalhost = target.kind === 'localhost-url'
|
||||
const typeInfo = describeFileType(target.normalizedPath ?? target.href)
|
||||
const icon = typeInfo.icon
|
||||
const badge = isLocalhost
|
||||
? t('assistantOutputs.kind.localhost')
|
||||
: target.kind === 'local-html'
|
||||
@@ -78,7 +78,7 @@ export function AssistantOutputTargetCard({ target, sessionId, workDir }: Props)
|
||||
{isLocalhost ? (
|
||||
<Globe size={17} strokeWidth={2.1} aria-hidden="true" />
|
||||
) : (
|
||||
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">{icon}</span>
|
||||
<FileTypeIcon path={target.normalizedPath ?? target.href} size={26} />
|
||||
)}
|
||||
</span>
|
||||
|
||||
|
||||
@@ -111,13 +111,13 @@ describe('AttachmentGallery', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
['report.pdf', 'PDF', 'picture_as_pdf'],
|
||||
['brief.docx', 'DOCX', 'docs'],
|
||||
['budget.xlsx', 'XLSX', 'table_chart'],
|
||||
['launch.pptx', 'PPTX', 'slideshow'],
|
||||
['sources.zip', 'ZIP', 'folder_zip'],
|
||||
['report.pdf', 'PDF', 'pdf'],
|
||||
['brief.docx', 'DOCX', 'docx'],
|
||||
['budget.xlsx', 'XLSX', 'xlsx'],
|
||||
['launch.pptx', 'PPTX', 'pptx'],
|
||||
['sources.zip', 'ZIP', 'zip'],
|
||||
['notes.md', 'MD', 'markdown'],
|
||||
])('renders a type-specific visual for %s', (name, extension, icon) => {
|
||||
])('renders a type-specific visual for %s', (name, extension, kind) => {
|
||||
const view = render(
|
||||
<AttachmentGallery
|
||||
attachments={[{
|
||||
@@ -128,7 +128,7 @@ describe('AttachmentGallery', () => {
|
||||
)
|
||||
|
||||
expect(view.container.querySelector(`[data-file-extension="${extension}"]`)).toBeInTheDocument()
|
||||
expect(view.getByText(icon)).toBeInTheDocument()
|
||||
expect(view.container.querySelector(`[data-file-type="${kind}"] svg`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens an absolute desktop attachment with the system default app', async () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useOpenTargetStore } from '../../stores/openTargetStore'
|
||||
import { useUIStore } from '../../stores/uiStore'
|
||||
import { OpenWithMenu } from '@/components/composite/OpenWithMenu'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { FileTypeIcon } from '@/components/ui/FileTypeIcon'
|
||||
import { IconButton } from '@/components/ui/IconButton'
|
||||
import { Tooltip } from '@/components/ui/Tooltip'
|
||||
import { ImageGalleryModal } from './ImageGalleryModal'
|
||||
@@ -375,7 +376,9 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[var(--radius-md)] bg-[var(--color-surface)] shadow-[inset_0_0_0_1px_var(--color-border)]"
|
||||
style={{ color: fileIconAccent(fileIcon) }}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[19px]">{fileIcon}</span>
|
||||
{attachment.isDirectory
|
||||
? <span className="material-symbols-outlined text-[19px]">{fileIcon}</span>
|
||||
: <FileTypeIcon path={attachment.path || attachment.name} size={20} />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block min-w-0 max-w-[260px] truncate text-[13px] font-semibold leading-5 text-[var(--color-text-primary)]">
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const fetchServerImageBlobUrl = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../../lib/authedImage', () => ({ fetchServerImageBlobUrl }))
|
||||
|
||||
import { AuthedImage } from './AuthedImage'
|
||||
|
||||
beforeEach(() => {
|
||||
fetchServerImageBlobUrl.mockReset()
|
||||
Object.defineProperty(URL, 'revokeObjectURL', { value: vi.fn(), configurable: true, writable: true })
|
||||
})
|
||||
|
||||
describe('AuthedImage', () => {
|
||||
it('shows the bare URL until it fails', () => {
|
||||
render(<AuthedImage src="http://127.0.0.1:1/a.png" alt="a" />)
|
||||
|
||||
expect(screen.getByRole('img')).toHaveAttribute('src', 'http://127.0.0.1:1/a.png')
|
||||
expect(fetchServerImageBlobUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('swaps in the authenticated copy without reporting a failure', async () => {
|
||||
fetchServerImageBlobUrl.mockResolvedValue('blob:x')
|
||||
const onFailure = vi.fn()
|
||||
render(<AuthedImage src="http://127.0.0.1:1/a.png" alt="a" onFailure={onFailure} />)
|
||||
|
||||
fireEvent.error(screen.getByRole('img'))
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('img')).toHaveAttribute('src', 'blob:x'))
|
||||
expect(onFailure).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a failure only after the authenticated attempt fails too', async () => {
|
||||
fetchServerImageBlobUrl.mockRejectedValue(new Error('403'))
|
||||
const onFailure = vi.fn()
|
||||
render(<AuthedImage src="http://127.0.0.1:1/a.png" alt="a" onFailure={onFailure} />)
|
||||
|
||||
fireEvent.error(screen.getByRole('img'))
|
||||
|
||||
await waitFor(() => expect(onFailure).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('does not apply a result that arrives after the image is gone, and frees it', async () => {
|
||||
let finish!: (url: string) => void
|
||||
fetchServerImageBlobUrl.mockReturnValue(new Promise<string>((resolve) => { finish = resolve }))
|
||||
const { unmount } = render(<AuthedImage src="http://127.0.0.1:1/a.png" alt="a" />)
|
||||
fireEvent.error(screen.getByRole('img'))
|
||||
|
||||
unmount()
|
||||
finish('blob:late')
|
||||
|
||||
await waitFor(() => expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:late'))
|
||||
})
|
||||
|
||||
it('treats a new src as a new image: it gets its own retry', async () => {
|
||||
fetchServerImageBlobUrl.mockResolvedValueOnce('blob:one').mockResolvedValueOnce('blob:two')
|
||||
const { rerender } = render(<AuthedImage src="http://127.0.0.1:1/one.png" alt="a" />)
|
||||
fireEvent.error(screen.getByRole('img'))
|
||||
await waitFor(() => expect(screen.getByRole('img')).toHaveAttribute('src', 'blob:one'))
|
||||
|
||||
rerender(<AuthedImage src="http://127.0.0.1:1/two.png" alt="a" />)
|
||||
expect(screen.getByRole('img')).toHaveAttribute('src', 'http://127.0.0.1:1/two.png')
|
||||
fireEvent.error(screen.getByRole('img'))
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('img')).toHaveAttribute('src', 'blob:two'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ImgHTMLAttributes } from 'react'
|
||||
import { useAuthedImageFallback } from '../../lib/useAuthedImageFallback'
|
||||
|
||||
type Props = Omit<ImgHTMLAttributes<HTMLImageElement>, 'onError'> & {
|
||||
/** Runs once the image has failed even with the app's credential. */
|
||||
onFailure?: () => void
|
||||
}
|
||||
|
||||
/** An `<img>` for a local-server URL that also loads where a bare request is refused (web UI, H5). */
|
||||
export function AuthedImage({ src, onFailure, alt = '', ...rest }: Props) {
|
||||
const image = useAuthedImageFallback(src, onFailure)
|
||||
return <img {...rest} alt={alt} src={image.src} onError={image.onError} />
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { SessionTurnCheckpoint } from '../../api/sessions'
|
||||
import { useTranslation, type TranslationKey } from '../../i18n'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { OpenWithMenu } from '@/components/composite/OpenWithMenu'
|
||||
import { FileTypeIcon } from '@/components/ui/FileTypeIcon'
|
||||
import { describeFileType, isPreviewableChangedFile, type OpenWithItem } from '../../lib/openWithItems'
|
||||
import { buildOpenWithMenuItems } from '../../lib/openWithMenuItems'
|
||||
import { openWithContextForWorkspaceFile } from '../../lib/openWithContextForHref'
|
||||
@@ -234,7 +235,7 @@ export function CurrentTurnChangeCard({
|
||||
title={fileEntry.displayPath}
|
||||
className="flex min-h-[52px] min-w-0 flex-1 items-center gap-3 rounded-[var(--radius-md)] px-4 text-left transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)]"
|
||||
>
|
||||
<span className="material-symbols-outlined shrink-0 text-[22px] text-[var(--color-text-tertiary)]">{typeInfo.icon}</span>
|
||||
<FileTypeIcon path={fileEntry.displayPath} size={24} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-[var(--color-text-primary)]">{fileName}</span>
|
||||
<span className="block truncate text-xs text-[var(--color-text-tertiary)]">{`${t(typeInfo.categoryKey as Parameters<typeof t>[0])} · ${typeInfo.ext}`}</span>
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useEffect } from 'react'
|
||||
import { ExternalLink, X } from 'lucide-react'
|
||||
import { IconButton } from '@/components/ui/IconButton'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { ZoomableImage } from '@/components/ui/ZoomableImage'
|
||||
import { ZoomableImage, type ZoomableImageProps } from '@/components/ui/ZoomableImage'
|
||||
import { useAuthedImageFallback } from '../../lib/useAuthedImageFallback'
|
||||
import { AuthedImage } from './AuthedImage'
|
||||
import { getDesktopHost } from '@/lib/desktopHost'
|
||||
import { isRootedLocalPath } from '@/lib/handlePreviewLink'
|
||||
import { openLocalFileWithSystem, reportOpenFailure } from '@/lib/systemFileOpen'
|
||||
@@ -27,6 +29,12 @@ type Props = {
|
||||
onSelect: (index: number) => void
|
||||
}
|
||||
|
||||
/** The lightbox picture, which also loads where a bare request is refused (web UI, H5). */
|
||||
function AuthedZoomableImage({ src, onError, ...props }: ZoomableImageProps) {
|
||||
const image = useAuthedImageFallback(src, onError)
|
||||
return <ZoomableImage {...props} src={image.src ?? src} onError={image.onError} />
|
||||
}
|
||||
|
||||
export function ImageGalleryModal({ open, images, activeIndex, onClose, onSelect }: Props) {
|
||||
const t = useTranslation()
|
||||
const activeImage = images[activeIndex]
|
||||
@@ -85,7 +93,7 @@ export function ImageGalleryModal({ open, images, activeIndex, onClose, onSelect
|
||||
</div>
|
||||
|
||||
<div className="relative flex min-h-0 flex-1 flex-col px-4 pb-4">
|
||||
<ZoomableImage
|
||||
<AuthedZoomableImage
|
||||
// The size it measures belongs to one picture, and so does the zoom.
|
||||
key={activeImage.src}
|
||||
src={activeImage.src}
|
||||
@@ -154,7 +162,7 @@ export function ImageGalleryModal({ open, images, activeIndex, onClose, onSelect
|
||||
: 'border-[var(--color-media-border)] opacity-55 hover:opacity-90'
|
||||
}`}
|
||||
>
|
||||
<img src={image.src} alt={image.name} className="h-12 w-12 object-cover" />
|
||||
<AuthedImage src={image.src} alt={image.name} className="h-12 w-12 object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ImageIcon, Maximize2, TriangleAlert } from 'lucide-react'
|
||||
import { Skeleton, SkeletonGroup } from '@/components/ui/Skeleton'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import { localImageFileUrl } from '@/lib/attachmentImages'
|
||||
import { AuthedImage } from './AuthedImage'
|
||||
import { ImageGalleryModal } from './ImageGalleryModal'
|
||||
|
||||
type GeneratedImage = {
|
||||
@@ -186,7 +187,7 @@ function ImageGenerationCollection({
|
||||
onClick={() => setActiveIndex(slot.galleryIndex!)}
|
||||
className="group/image relative aspect-square w-full overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border-separator)] bg-[var(--color-surface-container-low)] text-left shadow-[var(--shadow-card)] transition-[border-color,box-shadow,transform] duration-200 hover:-translate-y-0.5 hover:border-[var(--color-outline)] hover:shadow-[var(--shadow-composer)] active:translate-y-0 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
|
||||
>
|
||||
<img
|
||||
<AuthedImage
|
||||
src={src}
|
||||
alt={t('tool.generatedImageAlt', { index: slot.galleryIndex + 1 })}
|
||||
loading="lazy"
|
||||
|
||||
@@ -14,19 +14,30 @@ vi.mock('../../lib/desktopRuntime', () => ({
|
||||
getServerBaseUrl: () => 'http://127.0.0.1:4321',
|
||||
}))
|
||||
|
||||
// The authenticated fallback an <img> error falls back to. It rejects by default,
|
||||
// which is what a missing or denied file does, so the failure notice shows.
|
||||
const fetchServerImageBlobUrl = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../../lib/authedImage', () => ({ fetchServerImageBlobUrl }))
|
||||
|
||||
import { InlineImageGallery } from './InlineImageGallery'
|
||||
|
||||
beforeEach(() => {
|
||||
fetchServerImageBlobUrl.mockReset().mockRejectedValue(new Error('403'))
|
||||
// jsdom ships no object-URL support.
|
||||
Object.defineProperty(URL, 'revokeObjectURL', { value: vi.fn(), configurable: true, writable: true })
|
||||
})
|
||||
|
||||
function imgSrcs(): string[] {
|
||||
return screen.getAllByRole('img').map((img) => (img as HTMLImageElement).getAttribute('src') ?? '')
|
||||
}
|
||||
|
||||
describe('InlineImageGallery', () => {
|
||||
it('shows a failed image notice and filename instead of hiding the gallery entry', () => {
|
||||
it('shows a failed image notice and filename instead of hiding the gallery entry', async () => {
|
||||
render(<InlineImageGallery text="See E:/test/denied.png" />)
|
||||
|
||||
fireEvent.error(screen.getByRole('img'))
|
||||
|
||||
const notice = screen.getByRole('alert')
|
||||
const notice = await screen.findByRole('alert')
|
||||
expect(notice).toBeVisible()
|
||||
expect(notice).toHaveTextContent('Unable to load image')
|
||||
expect(notice).toHaveTextContent('denied.png')
|
||||
@@ -34,9 +45,10 @@ describe('InlineImageGallery', () => {
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeVisible()
|
||||
})
|
||||
|
||||
it('keeps other images usable and tracks failures by source when the list changes', () => {
|
||||
it('keeps other images usable and tracks failures by source when the list changes', async () => {
|
||||
const { rerender } = render(<InlineImageGallery text="See /tmp/denied.png and /tmp/allowed.png" />)
|
||||
fireEvent.error(screen.getByRole('img', { name: 'denied.png' }))
|
||||
await screen.findByRole('alert')
|
||||
|
||||
expect(screen.getByRole('img', { name: 'allowed.png' })).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: /allowed.png/ }))
|
||||
@@ -49,21 +61,74 @@ describe('InlineImageGallery', () => {
|
||||
expect(screen.queryByRole('img', { name: 'denied.png' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('retries the same protected URL and keeps feedback if the retry fails', () => {
|
||||
it('retries the same protected URL and keeps feedback if the retry fails', async () => {
|
||||
render(<InlineImageGallery text="See /tmp/denied.png" />)
|
||||
const source = screen.getByRole('img').getAttribute('src')
|
||||
fireEvent.error(screen.getByRole('img'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Retry' }))
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('img')).toHaveAttribute('src', source)
|
||||
fireEvent.error(screen.getByRole('img'))
|
||||
expect(screen.getByRole('alert')).toBeVisible()
|
||||
expect(await screen.findByRole('alert')).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
fireEvent.load(screen.getByRole('img'))
|
||||
expect(screen.getByRole('img')).toBeVisible()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to an authenticated fetch when the bare <img> is refused, as in the web UI', async () => {
|
||||
fetchServerImageBlobUrl.mockResolvedValue('blob:http://localhost/chart')
|
||||
render(<InlineImageGallery text="See /tmp/chart.png" />)
|
||||
const source = screen.getByRole('img').getAttribute('src')!
|
||||
|
||||
fireEvent.error(screen.getByRole('img'))
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('img')).toHaveAttribute('src', 'blob:http://localhost/chart'))
|
||||
expect(fetchServerImageBlobUrl).toHaveBeenCalledWith(source)
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('retries the full-size view with the credential too when the lightbox picture is refused', async () => {
|
||||
render(<InlineImageGallery text="See /tmp/chart.png" />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /chart.png/ }))
|
||||
const dialog = screen.getByRole('dialog')
|
||||
const refused = dialog.querySelector('img')!.getAttribute('src')
|
||||
fetchServerImageBlobUrl.mockResolvedValue('blob:http://localhost/chart-large')
|
||||
|
||||
fireEvent.error(dialog.querySelector('img')!)
|
||||
|
||||
await waitFor(() => expect(dialog.querySelector('img')).toHaveAttribute('src', 'blob:http://localhost/chart-large'))
|
||||
expect(fetchServerImageBlobUrl).toHaveBeenCalledWith(refused)
|
||||
})
|
||||
|
||||
it('tries the authenticated fetch only once per image: a broken blob is a real failure', async () => {
|
||||
fetchServerImageBlobUrl.mockResolvedValue('blob:http://localhost/broken')
|
||||
render(<InlineImageGallery text="See /tmp/broken.png" />)
|
||||
fireEvent.error(screen.getByRole('img'))
|
||||
await waitFor(() => expect(screen.getByRole('img')).toHaveAttribute('src', 'blob:http://localhost/broken'))
|
||||
|
||||
fireEvent.error(screen.getByRole('img'))
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('broken.png')
|
||||
expect(fetchServerImageBlobUrl).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ignores a late authenticated result that belongs to the previous session', async () => {
|
||||
let finish!: (url: string) => void
|
||||
fetchServerImageBlobUrl.mockReturnValue(new Promise<string>((resolve) => { finish = resolve }))
|
||||
const revoke = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
const { rerender } = render(<InlineImageGallery text="See /tmp/chart.png" sessionId="old" workDir="/tmp/old" />)
|
||||
const source = screen.getByRole('img').getAttribute('src')
|
||||
fireEvent.error(screen.getByRole('img'))
|
||||
|
||||
rerender(<InlineImageGallery text="See /tmp/chart.png" sessionId="new" workDir="/tmp/old" />)
|
||||
finish('blob:http://localhost/late')
|
||||
|
||||
await waitFor(() => expect(revoke).toHaveBeenCalledWith('blob:http://localhost/late'))
|
||||
expect(screen.getByRole('img')).toHaveAttribute('src', source)
|
||||
revoke.mockRestore()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ sessionId: 'new-session', workDir: '/tmp/old' },
|
||||
{ sessionId: 'old-session', workDir: '/tmp/new' },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { ErrorState } from '@/components/ui/ErrorState'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import { AuthedImage } from './AuthedImage'
|
||||
import { ImageGalleryModal } from './ImageGalleryModal'
|
||||
import { isManagedGeneratedImagePath, localImageFileUrl } from '../../lib/attachmentImages'
|
||||
import {
|
||||
@@ -184,22 +185,20 @@ export function InlineImageGallery({ text, sessionId, workDir, changedFiles, sup
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
key={img.src}
|
||||
key={`${sessionId ?? ''}|${workDir ?? ''}|${img.src}`}
|
||||
type="button"
|
||||
onClick={() => setActiveIndex(i)}
|
||||
className="group/image relative overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-left shadow-[var(--shadow-card)] transition-[border-color,box-shadow] duration-150 hover:shadow-[var(--shadow-composer)] hover:border-[var(--color-primary-fixed-dim)]"
|
||||
>
|
||||
<img
|
||||
<AuthedImage
|
||||
src={img.src}
|
||||
alt={img.name}
|
||||
loading="lazy"
|
||||
className="w-full object-cover"
|
||||
style={{ maxHeight: images.length === 1 ? 400 : 240 }}
|
||||
onError={() => {
|
||||
// img errors expose no HTTP status: a denied, missing or invalid
|
||||
// image needs visible feedback without claiming a specific cause.
|
||||
setFailureState((previous) => ({ ...previous, sources: new Set(previous.sources).add(img.src) }))
|
||||
}}
|
||||
// img errors expose no HTTP status: a denied, missing or invalid
|
||||
// image needs visible feedback without claiming a specific cause.
|
||||
onFailure={() => setFailureState((previous) => ({ ...previous, sources: new Set(previous.sources).add(img.src) }))}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/0 opacity-0 transition-all group-hover/image:bg-black/20 group-hover/image:opacity-100">
|
||||
<span className="material-symbols-outlined rounded-full bg-white/90 p-2 text-[20px] text-[var(--color-text-primary)] shadow-lg">
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import { fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const apiGetBlob = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../../api/client', async (original) => ({
|
||||
...(await original<Record<string, unknown>>()),
|
||||
apiGetBlob,
|
||||
getBaseUrl: () => 'http://127.0.0.1:3456',
|
||||
}))
|
||||
|
||||
import { MarkdownRenderer } from './MarkdownRenderer'
|
||||
|
||||
const LOCAL = 'http://127.0.0.1:3456/api/filesystem/file?path=%2Ftmp%2Fchart.png'
|
||||
|
||||
beforeEach(() => {
|
||||
apiGetBlob.mockReset().mockResolvedValue(new Blob(['png'], { type: 'image/png' }))
|
||||
Object.defineProperty(URL, 'createObjectURL', { value: vi.fn(() => 'blob:http://localhost/chart'), configurable: true, writable: true })
|
||||
Object.defineProperty(URL, 'revokeObjectURL', { value: vi.fn(), configurable: true, writable: true })
|
||||
})
|
||||
|
||||
describe('MarkdownRenderer local images', () => {
|
||||
it('retries a refused local image with the app credential', async () => {
|
||||
const { container } = render(
|
||||
<MarkdownRenderer content="" resolveImageSrc={() => LOCAL} />,
|
||||
)
|
||||
const image = container.querySelector('img')!
|
||||
expect(image).toHaveAttribute('src', LOCAL)
|
||||
|
||||
fireEvent.error(image)
|
||||
|
||||
await waitFor(() => expect(container.querySelector('img')).toHaveAttribute('src', 'blob:http://localhost/chart'))
|
||||
expect(apiGetBlob).toHaveBeenCalledWith('/api/filesystem/file?path=%2Ftmp%2Fchart.png')
|
||||
})
|
||||
|
||||
it('also covers a document with code blocks, which render in separate parts', async () => {
|
||||
const { container } = render(
|
||||
<MarkdownRenderer content={'\n\n```ts\nconst a = 1\n```'} resolveImageSrc={() => LOCAL} />,
|
||||
)
|
||||
|
||||
fireEvent.error(container.querySelector('img')!)
|
||||
|
||||
await waitFor(() => expect(container.querySelector('img')).toHaveAttribute('src', 'blob:http://localhost/chart'))
|
||||
})
|
||||
|
||||
it('never sends the credential for a remote image', async () => {
|
||||
const { container } = render(
|
||||
<MarkdownRenderer content="" resolveImageSrc={(src) => src} />,
|
||||
)
|
||||
|
||||
fireEvent.error(container.querySelector('img')!)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(apiGetBlob).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useMemo, useCallback } from 'react'
|
||||
import { memo, useMemo, useCallback, useRef } from 'react'
|
||||
import type { MouseEvent as ReactMouseEvent } from 'react'
|
||||
import DOMPurify from 'dompurify'
|
||||
import katex from 'katex'
|
||||
@@ -20,6 +20,7 @@ import { isSafeMarkdownImageSource, normalizeMarkdownImageDestination } from '@/
|
||||
import { CodeViewer } from '../chat/CodeViewer'
|
||||
import { MermaidRenderer } from '../chat/MermaidRenderer'
|
||||
import { copyTextToClipboard } from '@/lib/clipboard'
|
||||
import { attachAuthedImageFallback } from '@/lib/authedImage'
|
||||
import { t } from '../../i18n'
|
||||
|
||||
type Props = {
|
||||
@@ -715,9 +716,18 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, varian
|
||||
}, 1500)
|
||||
}, [onImageClick, onLinkClick])
|
||||
|
||||
// Local images the server refuses to hand to a bare <img> (web UI, H5) get one
|
||||
// authenticated retry; a callback ref keeps the listener on whichever div renders.
|
||||
const detachImageFallback = useRef<(() => void) | null>(null)
|
||||
const imageFallbackRef = useCallback((node: HTMLDivElement | null) => {
|
||||
detachImageFallback.current?.()
|
||||
detachImageFallback.current = node ? attachAuthedImageFallback(node) : null
|
||||
}, [])
|
||||
|
||||
if (codeBlocks.length === 0) {
|
||||
return (
|
||||
<div
|
||||
ref={imageFallbackRef}
|
||||
className={proseClasses}
|
||||
dangerouslySetInnerHTML={{ __html: parts[0]?.type === 'html' ? parts[0].content : '' }}
|
||||
onClick={handleClick}
|
||||
@@ -726,7 +736,7 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, varian
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={proseClasses} onClick={handleClick}>
|
||||
<div ref={imageFallbackRef} className={proseClasses} onClick={handleClick}>
|
||||
{parts.map((part, i) =>
|
||||
part.type === 'html' ? (
|
||||
<div key={i} dangerouslySetInnerHTML={{ __html: part.content }} />
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import { render } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { FileTypeIcon } from './FileTypeIcon'
|
||||
|
||||
describe('FileTypeIcon', () => {
|
||||
it.each([
|
||||
['报告.pdf', 'pdf', 'PDF'],
|
||||
['README.md', 'markdown', 'MD'],
|
||||
['简报.docx', 'docx', 'DOCX'],
|
||||
['C:\\out\\data.xlsx', 'xlsx', 'XLSX'],
|
||||
['table.xlsm', 'xlsx', 'XLSM'],
|
||||
['deck.pptx', 'pptx', 'PPTX'],
|
||||
['bundle.zip', 'zip', 'ZIP'],
|
||||
['main.rs', 'code', 'RS'],
|
||||
['notes.mdx', 'markdown', 'MDX'],
|
||||
['events.jsonl', 'json', 'JSONL'],
|
||||
])('renders a labelled icon for %s', (path, kind, label) => {
|
||||
const { container } = render(<FileTypeIcon path={path} />)
|
||||
const root = container.firstElementChild as HTMLElement
|
||||
expect(root).toHaveAttribute('data-file-type', kind)
|
||||
expect(root).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(root.querySelector('svg')).not.toBeNull()
|
||||
expect(root.textContent?.toUpperCase()).toContain(label)
|
||||
})
|
||||
|
||||
it('falls back to a neutral icon for extensionless and unknown files', () => {
|
||||
for (const path of ['Makefile', 'weird.zzz']) {
|
||||
const { container } = render(<FileTypeIcon path={path} />)
|
||||
expect(container.firstElementChild).toHaveAttribute('data-file-type', 'file')
|
||||
expect(container.querySelector('svg')).not.toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps markdown blue, distinct from the grey library default', () => {
|
||||
const { container } = render(<FileTypeIcon path="a.md" />)
|
||||
expect(container.innerHTML.toLowerCase()).toContain('#3b6fe0')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import { FileIcon, defaultStyles, type FileIconProps } from 'react-file-icon'
|
||||
import { describeFileType } from '@/lib/openWithItems'
|
||||
import { FILE_GROUP_COLORS, shadeHex } from '@/lib/fileTypePalette'
|
||||
|
||||
type IconStyle = Partial<FileIconProps>
|
||||
|
||||
const EXTENSION_ALIASES: Record<string, string> = {
|
||||
docm: 'docx',
|
||||
pages: 'docx',
|
||||
xlsm: 'xlsx',
|
||||
numbers: 'xlsx',
|
||||
pptm: 'pptx',
|
||||
key: 'pptx',
|
||||
markdown: 'md',
|
||||
mdx: 'md',
|
||||
log: 'txt',
|
||||
rst: 'txt',
|
||||
yaml: 'yml',
|
||||
tsx: 'ts',
|
||||
mjs: 'js',
|
||||
cjs: 'js',
|
||||
jsonl: 'json',
|
||||
xhtml: 'html',
|
||||
tgz: 'gz',
|
||||
bz2: 'gz',
|
||||
xz: 'gz',
|
||||
'7z': '7zip',
|
||||
opus: 'ogg',
|
||||
avif: 'png',
|
||||
webp: 'png',
|
||||
ico: 'png',
|
||||
}
|
||||
|
||||
/** Glyph used when the library has no style for the extension. */
|
||||
const GROUP_GLYPHS: Record<string, IconStyle['type']> = {
|
||||
picture_as_pdf: 'acrobat',
|
||||
docs: 'document',
|
||||
markdown: 'document',
|
||||
text_snippet: 'document',
|
||||
table_chart: 'spreadsheet',
|
||||
slideshow: 'presentation',
|
||||
folder_zip: 'compressed',
|
||||
audio_file: 'audio',
|
||||
video_file: 'video',
|
||||
html: 'code',
|
||||
image: 'image',
|
||||
code: 'code',
|
||||
}
|
||||
|
||||
function resolveIconStyle(path: string): { label: string; style: IconStyle; kind: string } {
|
||||
const { ext, icon } = describeFileType(path)
|
||||
const label = ext.toLowerCase()
|
||||
const key = EXTENSION_ALIASES[label] ?? label
|
||||
const known = (defaultStyles as Record<string, IconStyle>)[key]
|
||||
const color = FILE_GROUP_COLORS[icon] ?? FILE_GROUP_COLORS.insert_drive_file!
|
||||
const style: IconStyle = {
|
||||
type: known?.type ?? GROUP_GLYPHS[icon] ?? 'document',
|
||||
color,
|
||||
labelColor: color,
|
||||
foldColor: shadeHex(color, 0.18),
|
||||
glyphColor: 'rgba(255,255,255,0.4)',
|
||||
labelUppercase: true,
|
||||
}
|
||||
const kind = key === 'md' ? 'markdown' : known ? key : icon === 'insert_drive_file' ? 'file' : icon === 'code' || icon === 'html' ? 'code' : icon
|
||||
return { label, style, kind }
|
||||
}
|
||||
|
||||
export type FileTypeIconProps = {
|
||||
path: string
|
||||
/** Rendered width in px; the glyph keeps the library's document aspect ratio. */
|
||||
size?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Folded-corner document glyph with a colored extension label, from react-file-icon. */
|
||||
export function FileTypeIcon({ path, size = 28, className = '' }: FileTypeIconProps) {
|
||||
const { label, style, kind } = resolveIconStyle(path)
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-file-type={kind}
|
||||
className={`inline-flex shrink-0 ${className}`}
|
||||
style={{ width: size }}
|
||||
>
|
||||
<FileIcon {...style} extension={label || undefined} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -210,6 +210,60 @@ describe('the shape of a sheet', () => {
|
||||
expect(grid.columnWidths[3]).toBe(72) // not said
|
||||
})
|
||||
|
||||
it('widens a column the file gives no width to fit its numbers, so a value is never cut to "12,000…"', async () => {
|
||||
const grid = await firstGrid(workbookOf([{ name: 'A', rows: [['项目', '预算(元)'], ['服务器', '1,234,567,890.00'], ['差旅', '8,000.00']] }]))
|
||||
|
||||
// 16 digits/separators at ~6.6px, plus the cell's padding.
|
||||
expect(grid.columnWidths[1]).toBeGreaterThanOrEqual(120)
|
||||
// A short CJK label still gets the default floor, not a sliver.
|
||||
expect(grid.columnWidths[0]).toBeGreaterThanOrEqual(72)
|
||||
})
|
||||
|
||||
it('leaves a short column at the default width', async () => {
|
||||
const grid = await firstGrid(workbookOf([{ name: 'A', rows: [['a', 'b'], ['c', 'd']] }]))
|
||||
|
||||
expect(grid.columnWidths).toEqual([72, 72])
|
||||
})
|
||||
|
||||
it('caps how far a long text can widen a column', async () => {
|
||||
const grid = await firstGrid(workbookOf([{ name: 'A', rows: [['x'.repeat(500)]] }]))
|
||||
|
||||
expect(grid.columnWidths[0]).toBe(360)
|
||||
})
|
||||
|
||||
it('does not widen a column for a merged heading that spills over its neighbours', async () => {
|
||||
const grid = await firstGrid(workbookOf([{
|
||||
name: 'A',
|
||||
rows: [['Quarterly budget summary for the whole group', '', ''], ['a', 'b', 'c']],
|
||||
merges: [{ s: { r: 0, c: 0 }, e: { r: 0, c: 2 } }],
|
||||
}]))
|
||||
|
||||
expect(grid.columnWidths).toEqual([72, 72, 72])
|
||||
})
|
||||
|
||||
it('widens a stated column for a number that would otherwise be cut, as Excel would show ####', async () => {
|
||||
// 12 characters is what the file asked for; the preview font needs more for this figure.
|
||||
const grid = await firstGrid(workbookOf([{
|
||||
name: 'A',
|
||||
rows: [['预算', 1234567890.12]],
|
||||
columns: [{ wpx: 40 }, { wpx: 40 }],
|
||||
}]))
|
||||
|
||||
expect(grid.columnWidths[1]).toBeGreaterThan(40)
|
||||
// The text column keeps the width the file set: clipping text is what Excel does too.
|
||||
expect(grid.columnWidths[0]).toBe(40)
|
||||
})
|
||||
|
||||
it('never widens a column the file sets, nor a hidden one', async () => {
|
||||
const grid = await firstGrid(workbookOf([{
|
||||
name: 'A',
|
||||
rows: [['x'.repeat(100), 'y'.repeat(100)]],
|
||||
columns: [{ wpx: 90 }, { hidden: true }],
|
||||
}]))
|
||||
|
||||
expect(grid.columnWidths).toEqual([90, 0])
|
||||
})
|
||||
|
||||
it('keeps column widths within what can be drawn', async () => {
|
||||
const grid = await firstGrid(workbookOf([{ name: 'A', rows: [['a', 'b']], columns: [{ wpx: 2 }, { wpx: 9000 }] }]))
|
||||
|
||||
|
||||
@@ -91,6 +91,9 @@ export type SpreadsheetEngineOptions = {
|
||||
const DEFAULT_COLUMN_WIDTH = 72
|
||||
const MIN_COLUMN_WIDTH = 28
|
||||
const MAX_COLUMN_WIDTH = 480
|
||||
/** A column grows to fit its text or numbers, up to this. */
|
||||
const MAX_AUTO_COLUMN_WIDTH = 360
|
||||
const CELL_PADDING = 18
|
||||
const MIN_ROW_HEIGHT = 16
|
||||
const MAX_ROW_HEIGHT = 400
|
||||
|
||||
@@ -120,6 +123,31 @@ function cellAlign(cell: SheetJs.CellObject): CellAlign {
|
||||
return 'left'
|
||||
}
|
||||
|
||||
/** Rendered width of cell text at the grid's 12px size; a CJK character is about twice a Latin one. */
|
||||
function textWidth(text: string): number {
|
||||
let width = 0
|
||||
for (const char of text) width += (char.codePointAt(0) ?? 0) >= 0x2e80 ? 12 : 6.6
|
||||
return width
|
||||
}
|
||||
|
||||
/**
|
||||
* How wide a column must be to show its cells whole. Excel would draw a number that does
|
||||
* not fit as `####`; a preview that cuts it to `12,000…` hides the value just the same, and
|
||||
* the preview's font is wider than the one the width was chosen for. `numbersOnly` is for a
|
||||
* column whose width the file states: its text may still be clipped, as Excel clips it, but
|
||||
* a number may not. A merged cell's text spills over its span, so it takes no part.
|
||||
*/
|
||||
function fitColumnWidth(cells: Grid['cells'], column: number, spilling: ReadonlySet<string>, numbersOnly: boolean): number {
|
||||
let widest = 0
|
||||
for (let row = 0; row < cells.length; row += 1) {
|
||||
const cell = cells[row]?.[column]
|
||||
if (!cell || spilling.has(`${row}:${column}`)) continue
|
||||
if (numbersOnly && cell.align !== 'right') continue
|
||||
widest = Math.max(widest, textWidth(cell.text))
|
||||
}
|
||||
return widest === 0 ? 0 : Math.min(Math.ceil(widest + CELL_PADDING), MAX_AUTO_COLUMN_WIDTH)
|
||||
}
|
||||
|
||||
/** Turn a dense SheetJS worksheet into the grid the viewer draws, and let SheetJS's objects go. */
|
||||
function toGrid(sheet: SheetJs.WorkSheet, limits: SpreadsheetLimits): Grid {
|
||||
const data = ((sheet as SheetJs.DenseSheet)['!data'] ?? []) as Array<Array<SheetJs.CellObject | undefined> | undefined>
|
||||
@@ -153,11 +181,19 @@ function toGrid(sheet: SheetJs.WorkSheet, limits: SpreadsheetLimits): Grid {
|
||||
cells.push(line)
|
||||
}
|
||||
|
||||
const spilling = new Set<string>()
|
||||
for (const range of sheet['!merges'] ?? []) {
|
||||
if (range.e.c > range.s.c) spilling.add(`${range.s.r}:${range.s.c}`)
|
||||
}
|
||||
|
||||
const columnInfo = sheet['!cols'] ?? []
|
||||
const columnWidths = Array.from({ length: columns }, (_, column) => {
|
||||
const info = columnInfo[column]
|
||||
if (info?.hidden) return 0
|
||||
const width = info?.wpx ?? (info?.wch ? info.wch * 7 + 5 : DEFAULT_COLUMN_WIDTH)
|
||||
const stated = info?.wpx ?? (info?.wch ? info.wch * 7 + 5 : undefined)
|
||||
const width = stated === undefined
|
||||
? Math.max(DEFAULT_COLUMN_WIDTH, fitColumnWidth(cells, column, spilling, false))
|
||||
: Math.max(stated, fitColumnWidth(cells, column, spilling, true))
|
||||
return clamp(Math.round(width), MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const apiGetBlob = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../api/client', () => ({
|
||||
apiGetBlob,
|
||||
getBaseUrl: () => 'http://127.0.0.1:3456',
|
||||
}))
|
||||
|
||||
import { attachAuthedImageFallback, fetchServerImageBlobUrl } from './authedImage'
|
||||
|
||||
beforeEach(() => {
|
||||
apiGetBlob.mockReset().mockResolvedValue(new Blob(['png'], { type: 'image/png' }))
|
||||
Object.defineProperty(URL, 'createObjectURL', { value: vi.fn(() => 'blob:http://localhost/x'), configurable: true, writable: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(URL, 'createObjectURL')
|
||||
})
|
||||
|
||||
describe('fetchServerImageBlobUrl', () => {
|
||||
it('fetches a local-server image through the credentialed client and returns an object URL', async () => {
|
||||
const src = `http://127.0.0.1:3456/api/filesystem/file?path=${encodeURIComponent('/tmp/fti work/chart.png')}`
|
||||
|
||||
await expect(fetchServerImageBlobUrl(src)).resolves.toBe('blob:http://localhost/x')
|
||||
|
||||
// Path and query only: the client prepends its own base URL and credential.
|
||||
expect(apiGetBlob).toHaveBeenCalledWith(`/api/filesystem/file?path=${encodeURIComponent('/tmp/fti work/chart.png')}`)
|
||||
})
|
||||
|
||||
it('serves a session preview URL the same way', async () => {
|
||||
await fetchServerImageBlobUrl('http://127.0.0.1:3456/preview-fs/s1/out/frame.png')
|
||||
|
||||
expect(apiGetBlob).toHaveBeenCalledWith('/preview-fs/s1/out/frame.png')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'https://example.com/cat.png',
|
||||
'http://127.0.0.1:9999/api/filesystem/file?path=%2Fetc%2Fhosts',
|
||||
'http://localhost:3456/api/filesystem/file?path=%2Ftmp%2Fa.png',
|
||||
])('never sends the credential to another origin (%s)', async (src) => {
|
||||
await expect(fetchServerImageBlobUrl(src)).rejects.toThrow('Not a local-server image URL')
|
||||
|
||||
expect(apiGetBlob).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('propagates a refusal so the caller can show the failure notice', async () => {
|
||||
apiGetBlob.mockRejectedValue(new Error('403'))
|
||||
|
||||
await expect(fetchServerImageBlobUrl('http://127.0.0.1:3456/api/filesystem/file?path=%2Ftmp%2Fa.png')).rejects.toThrow('403')
|
||||
})
|
||||
})
|
||||
|
||||
describe('attachAuthedImageFallback', () => {
|
||||
const localSrc = 'http://127.0.0.1:3456/api/filesystem/file?path=%2Ftmp%2Fa.png'
|
||||
|
||||
function mount(src: string) {
|
||||
const container = document.createElement('div')
|
||||
container.innerHTML = `<p><img alt="a" src="${src}"></p>`
|
||||
document.body.appendChild(container)
|
||||
const detach = attachAuthedImageFallback(container)
|
||||
return { container, image: container.querySelector('img')!, detach }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(URL, 'revokeObjectURL', { value: vi.fn(), configurable: true, writable: true })
|
||||
})
|
||||
|
||||
it('swaps a refused local image for an authenticated copy', async () => {
|
||||
const { image, detach } = mount(localSrc)
|
||||
|
||||
image.dispatchEvent(new Event('error'))
|
||||
|
||||
await vi.waitFor(() => expect(image.getAttribute('src')).toBe('blob:http://localhost/x'))
|
||||
detach()
|
||||
})
|
||||
|
||||
it('retries each image only once, so a broken body does not loop', async () => {
|
||||
const { image, detach } = mount(localSrc)
|
||||
image.dispatchEvent(new Event('error'))
|
||||
await vi.waitFor(() => expect(image.getAttribute('src')).toBe('blob:http://localhost/x'))
|
||||
|
||||
image.dispatchEvent(new Event('error'))
|
||||
|
||||
expect(apiGetBlob).toHaveBeenCalledTimes(1)
|
||||
detach()
|
||||
})
|
||||
|
||||
it.each(['https://example.com/cat.png', 'data:image/png;base64,AAAA'])('leaves %s alone', async (src) => {
|
||||
const { image, detach } = mount(src)
|
||||
|
||||
image.dispatchEvent(new Event('error'))
|
||||
await Promise.resolve()
|
||||
|
||||
expect(apiGetBlob).not.toHaveBeenCalled()
|
||||
expect(image.getAttribute('src')).toBe(src)
|
||||
detach()
|
||||
})
|
||||
|
||||
it('frees its object URLs and stops listening once detached', async () => {
|
||||
const { image, detach } = mount(localSrc)
|
||||
image.dispatchEvent(new Event('error'))
|
||||
await vi.waitFor(() => expect(image.getAttribute('src')).toBe('blob:http://localhost/x'))
|
||||
|
||||
detach()
|
||||
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:http://localhost/x')
|
||||
const other = document.createElement('img')
|
||||
other.setAttribute('src', localSrc)
|
||||
image.parentElement!.appendChild(other)
|
||||
other.dispatchEvent(new Event('error'))
|
||||
expect(apiGetBlob).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { apiGetBlob, getBaseUrl } from '../api/client'
|
||||
|
||||
/**
|
||||
* Fetch a local-server image with the app's credential and hand back a `blob:` URL.
|
||||
*
|
||||
* A bare `<img src>` cannot send `Authorization`, and the server refuses a
|
||||
* credential-less cross-site subresource load (it cannot tell the page from a
|
||||
* hostile one). The packaged desktop shell works around that by injecting the
|
||||
* header for an allowlist of media routes; a plain browser tab — the web UI, a LAN
|
||||
* or remote H5 client — has no such hook, so its images go through this fetch.
|
||||
*
|
||||
* Only URLs on the local server's own origin are fetched: the credential must not
|
||||
* follow an arbitrary image URL to another host.
|
||||
*/
|
||||
export async function fetchServerImageBlobUrl(src: string): Promise<string> {
|
||||
const base = new URL(getBaseUrl())
|
||||
const target = new URL(src, base)
|
||||
if (target.origin !== base.origin) throw new Error('Not a local-server image URL')
|
||||
const blob = await apiGetBlob(`${target.pathname}${target.search}`)
|
||||
return URL.createObjectURL(blob)
|
||||
}
|
||||
|
||||
/**
|
||||
* The same fallback for images that arrive as sanitized HTML (rendered Markdown),
|
||||
* where there is no component to hang `onError` on: one capturing listener on the
|
||||
* container retries a failed `<img>` per element, once. Returns the detach function,
|
||||
* which also frees the object URLs it handed out.
|
||||
*/
|
||||
export function attachAuthedImageFallback(container: HTMLElement): () => void {
|
||||
const objectUrls: string[] = []
|
||||
let detached = false
|
||||
|
||||
const onError = (event: Event) => {
|
||||
const image = event.target
|
||||
if (!(image instanceof HTMLImageElement) || image.dataset.authedFallback) return
|
||||
const src = image.getAttribute('src')
|
||||
if (!src || src.startsWith('blob:') || src.startsWith('data:')) return
|
||||
image.dataset.authedFallback = '1'
|
||||
void fetchServerImageBlobUrl(src).then((url) => {
|
||||
if (detached) {
|
||||
URL.revokeObjectURL(url)
|
||||
return
|
||||
}
|
||||
objectUrls.push(url)
|
||||
image.src = url
|
||||
}).catch(() => {
|
||||
// Not a local-server image, or refused: the browser's own broken-image state stands.
|
||||
})
|
||||
}
|
||||
|
||||
// `error` does not bubble, so the listener has to capture.
|
||||
container.addEventListener('error', onError, true)
|
||||
return () => {
|
||||
detached = true
|
||||
container.removeEventListener('error', onError, true)
|
||||
for (const url of objectUrls) URL.revokeObjectURL(url)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Brand colors for file-type icons, keyed by `describeFileType().icon`.
|
||||
*
|
||||
* These are identity colors (PDF red, Word blue, ...) that must read the same in
|
||||
* every theme, and react-file-icon writes them into SVG attributes where CSS
|
||||
* variables are not reliable, so they live here rather than in theme tokens.
|
||||
*/
|
||||
export const FILE_GROUP_COLORS: Record<string, string> = {
|
||||
picture_as_pdf: '#D93831',
|
||||
docs: '#2C5898',
|
||||
markdown: '#3B6FE0',
|
||||
text_snippet: '#667085',
|
||||
table_chart: '#1A754C',
|
||||
slideshow: '#D14423',
|
||||
folder_zip: '#B7791F',
|
||||
audio_file: '#AD477C',
|
||||
video_file: '#6655B8',
|
||||
html: '#C05D2C',
|
||||
image: '#24899A',
|
||||
code: '#7656B5',
|
||||
insert_drive_file: '#667085',
|
||||
}
|
||||
|
||||
/** Darken a #rrggbb color by `amount` (0-1), used for the folded corner. */
|
||||
export function shadeHex(hex: string, amount: number): string {
|
||||
const channel = (offset: number) => Math.round(parseInt(hex.slice(offset, offset + 2), 16) * (1 - amount)).toString(16).padStart(2, '0')
|
||||
return `#${channel(1)}${channel(3)}${channel(5)}`
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
import { waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const openPath = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
|
||||
// The server's verdict on a document the string check cannot place. Refusing
|
||||
// (the 403 a path outside the workspace gets) is the default.
|
||||
const getWorkspaceFile = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../api/sessions', () => ({
|
||||
sessionsApi: { getWorkspaceFile },
|
||||
}))
|
||||
|
||||
vi.mock('./desktopHost', () => ({
|
||||
getDesktopHost: () => ({
|
||||
@@ -24,13 +31,23 @@ vi.mock('./workspace/openTarget', () => ({
|
||||
|
||||
vi.mock('../stores/workspaceContentStore', () => ({
|
||||
useWorkspaceContentStore: {
|
||||
getState: () => ({ statusBySession: { s1: { workDir: '/work' } } }),
|
||||
getState: () => ({
|
||||
statusBySession: {
|
||||
s1: { workDir: '/work' },
|
||||
// The server reports the canonical workdir; the chat writes the symlinked form.
|
||||
s3: { workDir: '/private/tmp/app' },
|
||||
},
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
import { openPreviewLink } from './openPreviewLink'
|
||||
import { workspaceOpen } from './workspace/openTarget'
|
||||
|
||||
beforeEach(() => {
|
||||
getWorkspaceFile.mockReset().mockRejectedValue(new Error('403 Path is outside workspace'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
openPath.mockReset().mockResolvedValue(undefined)
|
||||
vi.mocked(workspaceOpen.file).mockClear()
|
||||
@@ -101,15 +118,44 @@ describe('openPreviewLink for a document the workspace can draw', () => {
|
||||
expect(openPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps an absolute document with the system application while the workdir is still unknown', async () => {
|
||||
// Session s2 has no workspace status yet. The system app is what this link
|
||||
// always did, and it always works; a preview tab that may 403 is a gamble.
|
||||
it('keeps an absolute document with the system application while the workdir is still unknown and the server refuses it', async () => {
|
||||
// Session s2 has no workspace status yet, so the string check cannot place the
|
||||
// path; the server's refusal is what sends it to the system app.
|
||||
openPreviewLink('/Users/x/Documents/thesis.pdf', 's2')
|
||||
|
||||
await waitFor(() => expect(openPath).toHaveBeenCalledWith('/Users/x/Documents/thesis.pdf'))
|
||||
expect(getWorkspaceFile).toHaveBeenCalledWith('s2', '/Users/x/Documents/thesis.pdf')
|
||||
expect(workspaceOpen.file).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('previews an absolute document the server accepts even though the workdir has not loaded yet', async () => {
|
||||
getWorkspaceFile.mockResolvedValue({ state: 'ok', path: '/work/out/thesis.pdf' })
|
||||
|
||||
openPreviewLink('/work/out/thesis.pdf', 's2')
|
||||
|
||||
await waitFor(() => expect(workspaceOpen.file).toHaveBeenCalledWith('s2', '/work/out/thesis.pdf', {}))
|
||||
expect(openPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('previews a document written through a symlink of the canonical workdir (/tmp vs /private/tmp)', async () => {
|
||||
// Regression: the output card for `/tmp/app/report.pdf` opened the system app,
|
||||
// because the session's canonical workdir is `/private/tmp/app` and the string
|
||||
// comparison called the document outside the workspace.
|
||||
getWorkspaceFile.mockResolvedValue({ state: 'ok', path: '/tmp/app/report.pdf' })
|
||||
|
||||
expect(openPreviewLink('/tmp/app/report.pdf', 's3')).toBe(true)
|
||||
|
||||
await waitFor(() => expect(workspaceOpen.file).toHaveBeenCalledWith('s3', '/tmp/app/report.pdf', {}))
|
||||
expect(getWorkspaceFile).toHaveBeenCalledWith('s3', '/tmp/app/report.pdf')
|
||||
expect(openPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not ask the server when the path is plainly inside the workdir', () => {
|
||||
openPreviewLink('/work/out/thesis.pdf', 's1')
|
||||
|
||||
expect(getWorkspaceFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still previews a relative document while the workdir is unknown', () => {
|
||||
openPreviewLink('out/thesis.pdf', 's2')
|
||||
|
||||
|
||||
@@ -4,20 +4,25 @@ import { getDesktopHost } from './desktopHost'
|
||||
import { isWithinWorkDir } from './assistantOutputTargets'
|
||||
import { isWorkspaceDocumentFile } from './fileCapabilities'
|
||||
import { useWorkspaceContentStore } from '../stores/workspaceContentStore'
|
||||
import { sessionsApi } from '../api/sessions'
|
||||
import { workspaceOpen } from './workspace/openTarget'
|
||||
import { openLocalFileWithSystem, reportOpenFailure, resolveAbsoluteOpenPath } from './systemFileOpen'
|
||||
|
||||
/**
|
||||
* Whether the workspace preview can actually reach this document.
|
||||
* Cheap, synchronous guess at whether the workspace preview can reach this document.
|
||||
*
|
||||
* The file routes serve the session workdir and the roots registered for files a
|
||||
* turn changed; a document elsewhere (`~/thesis.docx`, another drive) would open
|
||||
* a tab that goes straight to a 403. Those keep going to the system application,
|
||||
* as every document link did before the workspace could render them. A relative
|
||||
* path is resolved against the workdir by the server, which is reachable unless its
|
||||
* `../` climbs out of the workdir — `../shared/spec.pdf` is as far outside as its
|
||||
* absolute form. An absolute path while the workdir is still unknown is treated as
|
||||
* unreachable: the system app is the outcome the user had before, and it always works.
|
||||
* a tab that goes straight to a 403. A relative path is resolved against the
|
||||
* workdir by the server, which is reachable unless its `../` climbs out of the
|
||||
* workdir — `../shared/spec.pdf` is as far outside as its absolute form.
|
||||
*
|
||||
* This is only a string comparison, so a `false` is not final: the workdir the
|
||||
* server reports is canonical (`/private/tmp/app`), while the path in the chat is
|
||||
* whatever form the model wrote (`/tmp/app/report.pdf`). Symlinked folders, registered
|
||||
* access roots and a workdir that has not loaded yet all look "outside" here. Only
|
||||
* the server, which resolves real paths, can settle those — see
|
||||
* {@link documentReachableOnServer}.
|
||||
*/
|
||||
function documentReachableInWorkspace(path: string, workDir: string | undefined): boolean {
|
||||
if (!isRootedLocalPath(path)) return !workDir || isWithinWorkDir(resolveAbsoluteOpenPath(path, workDir), workDir)
|
||||
@@ -25,6 +30,20 @@ function documentReachableInWorkspace(path: string, workDir: string | undefined)
|
||||
return isWithinWorkDir(path, workDir)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the server whether it will serve this document in the workspace. It resolves
|
||||
* real paths and registered roots, so it answers what the string check above cannot;
|
||||
* a refusal (403) or an unreachable server means "keep it with the system app".
|
||||
*/
|
||||
async function documentReachableOnServer(sessionId: string, path: string): Promise<boolean> {
|
||||
try {
|
||||
await sessionsApi.getWorkspaceFile(sessionId, path)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a clicked link the way the chat surface always has: a loopback URL opens
|
||||
* the workbench browser on the right, a workspace file opens its preview, and a
|
||||
@@ -48,12 +67,18 @@ export function openPreviewLink(href: string, sessionId: string): boolean {
|
||||
serverBaseUrl: getServerBaseUrl(),
|
||||
openBrowser: (id, url) => { workspaceOpen.browser(id, url) },
|
||||
openFilePreview: (id, path, reveal) => {
|
||||
if (isWorkspaceDocumentFile(path) && !documentReachableInWorkspace(path, currentWorkDir())) {
|
||||
openSystemFile(path)
|
||||
const openInWorkspace = () => {
|
||||
workspaceOpen.file(id, path, {
|
||||
...(reveal ? { line: reveal.line, ...(reveal.column ? { column: reveal.column } : {}) } : {}),
|
||||
})
|
||||
}
|
||||
if (!isWorkspaceDocumentFile(path) || documentReachableInWorkspace(path, currentWorkDir())) {
|
||||
openInWorkspace()
|
||||
return
|
||||
}
|
||||
workspaceOpen.file(id, path, {
|
||||
...(reveal ? { line: reveal.line, ...(reveal.column ? { column: reveal.column } : {}) } : {}),
|
||||
void documentReachableOnServer(id, path).then((reachable) => {
|
||||
if (reachable) openInWorkspace()
|
||||
else openSystemFile(path)
|
||||
})
|
||||
},
|
||||
openSystemFile,
|
||||
|
||||
@@ -17,7 +17,7 @@ const FILE_TYPE_RULES: Array<{ re: RegExp; key: string; icon: string }> = [
|
||||
{ re: /\.(mp4|mov|m4v|webm|mkv|avi)$/i, key: 'video', icon: 'video_file' },
|
||||
{ re: /\.(html?|xhtml)$/i, key: 'web', icon: 'html' },
|
||||
{ re: /\.(png|jpe?g|gif|svg|webp|avif|bmp|ico)$/i, key: 'image', icon: 'image' },
|
||||
{ re: /\.(ts|tsx|js|jsx|mjs|cjs|json|css|scss|less|py|rs|go|java|rb|php|c|cc|cpp|h|hpp|sh|ya?ml|toml|xml|sql)$/i, key: 'code', icon: 'code' },
|
||||
{ re: /\.(ts|tsx|js|jsx|mjs|cjs|json|jsonl|css|scss|less|py|rs|go|java|rb|php|c|cc|cpp|h|hpp|sh|ya?ml|toml|xml|sql)$/i, key: 'code', icon: 'code' },
|
||||
]
|
||||
|
||||
export function describeFileType(path: string): FileTypeInfo {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { fetchServerImageBlobUrl } from './authedImage'
|
||||
|
||||
/**
|
||||
* Let an `<img>` that a plain request could not load try once more with the app's
|
||||
* credential (see {@link fetchServerImageBlobUrl}).
|
||||
*
|
||||
* Spread `src` and `onError` onto the image. `onFailure` runs only when the
|
||||
* authenticated attempt has also failed — a missing or denied file, or a body that
|
||||
* is not an image — so callers keep their own failure notice for real failures and
|
||||
* never flash it for a request that was merely missing a header.
|
||||
*/
|
||||
export function useAuthedImageFallback(src: string | undefined, onFailure?: () => void) {
|
||||
const [resolved, setResolved] = useState<{ source: string; url: string } | null>(null)
|
||||
const triedSource = useRef<string | undefined>(undefined)
|
||||
const alive = useRef(true)
|
||||
const objectUrls = useRef<string[]>([])
|
||||
const onFailureRef = useRef(onFailure)
|
||||
onFailureRef.current = onFailure
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true
|
||||
const owned = objectUrls.current
|
||||
return () => {
|
||||
alive.current = false
|
||||
for (const url of owned) URL.revokeObjectURL(url)
|
||||
owned.length = 0
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onError = useCallback(() => {
|
||||
if (!src || triedSource.current === src) {
|
||||
onFailureRef.current?.()
|
||||
return
|
||||
}
|
||||
triedSource.current = src
|
||||
void fetchServerImageBlobUrl(src).then((url) => {
|
||||
if (!alive.current) {
|
||||
URL.revokeObjectURL(url)
|
||||
return
|
||||
}
|
||||
objectUrls.current.push(url)
|
||||
setResolved({ source: src, url })
|
||||
}).catch(() => {
|
||||
if (alive.current && triedSource.current === src) onFailureRef.current?.()
|
||||
})
|
||||
}, [src])
|
||||
|
||||
return { src: resolved && resolved.source === src ? resolved.url : src, onError }
|
||||
}
|
||||
Reference in New Issue
Block a user