diff --git a/claude-notes/plans/2026-07-01-zip-export-absolute-paths.md b/claude-notes/plans/2026-07-01-zip-export-absolute-paths.md new file mode 100644 index 000000000..9b1770843 Binary files /dev/null and b/claude-notes/plans/2026-07-01-zip-export-absolute-paths.md differ diff --git a/hub-client/changelog.md b/hub-client/changelog.md index 7e56c43af..ba38b713d 100644 --- a/hub-client/changelog.md +++ b/hub-client/changelog.md @@ -15,6 +15,10 @@ be in reverse chronological order (latest first). --> +### 2026-07-01 + +- [`21be266a`](https://github.com/quarto-dev/q2/commits/21be266a): Downloaded project ZIPs now use relative paths nested under a single project-name folder (e.g. `Demo-Playground/index.qmd`) instead of absolute paths — `unzip` no longer warns about "stripped absolute path spec" and the archive extracts into one tidy directory. + ### 2026-06-25 - [`d6066dc9`](https://github.com/quarto-dev/q2/commits/d6066dc9): The editing toolbar (and breadcrumb navigator) no longer gets cut off when you edit the very first block of a document with no title — it now flips below the block when there isn't room above. diff --git a/hub-client/src/components/tabs/ProjectTab.test.tsx b/hub-client/src/components/tabs/ProjectTab.test.tsx new file mode 100644 index 000000000..0006cafb3 --- /dev/null +++ b/hub-client/src/components/tabs/ProjectTab.test.tsx @@ -0,0 +1,114 @@ +/** + * Tests for the "Export ZIP" wiring in ProjectTab. + * + * Scope: the component derives ONE project-folder slug and uses it for both + * the in-archive top-level folder (passed to `onExportZip`) and the download + * filename stem, so the two can never drift (GH #147). The slug is sanitized + * via `projectFolderName`, so a hostile character in the project name must not + * leak into either. + * + * The path normalization itself is exhaustively covered by node-env unit tests + * (quarto-sync-client/export-zip.test.ts and project-folder-name.test.ts); here + * we only assert the UI hands the same, sanitized slug to both consumers. + * + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import ProjectTab from './ProjectTab'; +import type { ProjectEntry } from '@quarto/preview-renderer/types/project'; + +function makeProject(description: string): ProjectEntry { + return { + id: 'local-1', + indexDocId: 'automerge:abc123', + syncServer: 'wss://example.test/sync', + description, + createdAt: '2026-07-01T00:00:00.000Z', + lastAccessed: '2026-07-01T00:00:00.000Z', + }; +} + +describe('ProjectTab — Export ZIP wiring', () => { + let clickedDownloadNames: string[]; + let createElementSpy: ReturnType; + + beforeEach(() => { + clickedDownloadNames = []; + // jsdom does not implement object URLs; stub them. + vi.stubGlobal('URL', { + ...URL, + createObjectURL: vi.fn(() => 'blob:mock'), + revokeObjectURL: vi.fn(), + }); + // Capture the download filename of any anchor the handler clicks. + const realCreateElement = document.createElement.bind(document); + createElementSpy = vi + .spyOn(document, 'createElement') + .mockImplementation((tag: string, opts?: ElementCreationOptions) => { + const el = realCreateElement(tag, opts); + if (tag === 'a') { + vi.spyOn(el as HTMLAnchorElement, 'click').mockImplementation(() => { + clickedDownloadNames.push((el as HTMLAnchorElement).download); + }); + } + return el; + }); + }); + + afterEach(() => { + createElementSpy.mockRestore(); + vi.unstubAllGlobals(); + cleanup(); + }); + + it('passes the sanitized slug as rootDir and reuses it for the filename', () => { + const onExportZip = vi.fn(() => new Uint8Array([1, 2, 3])); + render( + {}} + onExportZip={onExportZip} + />, + ); + + fireEvent.click(screen.getByText('Export ZIP')); + + expect(onExportZip).toHaveBeenCalledWith('Demo-Playground'); + expect(clickedDownloadNames).toEqual(['Demo-Playground.zip']); + }); + + it('sanitizes hostile characters in the project name for both outputs', () => { + const onExportZip = vi.fn(() => new Uint8Array([1])); + render( + {}} + onExportZip={onExportZip} + />, + ); + + fireEvent.click(screen.getByText('Export ZIP')); + + // ':' and '?' collapse to hyphens; folder and filename stay in lock-step. + expect(onExportZip).toHaveBeenCalledWith('Demo-Playground'); + expect(clickedDownloadNames).toEqual(['Demo-Playground.zip']); + }); + + it('falls back to "project" when the name is empty', () => { + const onExportZip = vi.fn(() => new Uint8Array([1])); + render( + {}} + onExportZip={onExportZip} + />, + ); + + fireEvent.click(screen.getByText('Export ZIP')); + + expect(onExportZip).toHaveBeenCalledWith('project'); + expect(clickedDownloadNames).toEqual(['project.zip']); + }); +}); diff --git a/hub-client/src/components/tabs/ProjectTab.tsx b/hub-client/src/components/tabs/ProjectTab.tsx index 24e686781..97cc66f18 100644 --- a/hub-client/src/components/tabs/ProjectTab.tsx +++ b/hub-client/src/components/tabs/ProjectTab.tsx @@ -9,13 +9,19 @@ */ import { useState, useCallback } from 'react'; +import { projectFolderName } from '@quarto/quarto-sync-client'; import type { ProjectEntry } from '@quarto/preview-renderer/types/project'; import './ProjectTab.css'; interface ProjectTabProps { project: ProjectEntry; onChooseNewProject: () => void; - onExportZip: () => Uint8Array; + /** + * Produce the project ZIP, nesting every entry under `rootDir` (the + * project-name folder). Callers should pass the same value used for the + * download filename stem so the folder and filename stay in lock-step. + */ + onExportZip: (rootDir: string) => Uint8Array; } export default function ProjectTab({ project, onChooseNewProject, onExportZip }: ProjectTabProps) { @@ -37,12 +43,15 @@ export default function ProjectTab({ project, onChooseNewProject, onExportZip }: setExporting(true); setExportError(null); try { - const zipBytes = onExportZip(); + // One slug drives both the in-archive top-level folder and the download + // filename stem, so they can never drift (GH #147). + const folderName = projectFolderName(project.description); + const zipBytes = onExportZip(folderName); const blob = new Blob([zipBytes.buffer as ArrayBuffer], { type: 'application/zip' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = `${(project.description || 'project').replace(/ /g, '-')}.zip`; + a.download = `${folderName}.zip`; a.click(); URL.revokeObjectURL(url); } catch (err) { diff --git a/ts-packages/preview-runtime/src/automergeSync.ts b/ts-packages/preview-runtime/src/automergeSync.ts index 7295e83f9..1db0d3333 100644 --- a/ts-packages/preview-runtime/src/automergeSync.ts +++ b/ts-packages/preview-runtime/src/automergeSync.ts @@ -304,10 +304,14 @@ export function getFilePaths(): string[] { /** * Export all project files as a ZIP archive. - * Returns a Uint8Array containing the ZIP file bytes. + * + * @param rootDir - Optional top-level folder to nest every entry under + * (typically the project's name). See {@link exportZip} for the path + * normalization applied (leading slashes stripped, folder sanitized). + * @returns a Uint8Array containing the ZIP file bytes. */ -export function exportProjectAsZip(): Uint8Array { - return exportZip(ensureClient()); +export function exportProjectAsZip(rootDir?: string): Uint8Array { + return exportZip(ensureClient(), rootDir); } /** diff --git a/ts-packages/quarto-sync-client/src/export-zip.test.ts b/ts-packages/quarto-sync-client/src/export-zip.test.ts index 663409711..a597d67f3 100644 --- a/ts-packages/quarto-sync-client/src/export-zip.test.ts +++ b/ts-packages/quarto-sync-client/src/export-zip.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { unzipSync, strFromU8 } from 'fflate'; import { exportProjectAsZip } from './export-zip.js'; +import { parseProjectZip } from './import-zip.js'; import type { SyncClient } from './client.js'; /** Create a mock SyncClient with the given text and binary files. */ @@ -142,4 +143,85 @@ describe('exportProjectAsZip', () => { expect(Object.keys(entries)).toHaveLength(1); expect(strFromU8(entries['exists.qmd'])).toBe('content'); }); + + // --- Absolute-path bug (GH #147) -------------------------------------- + + it('strips a leading slash from stored paths (no rootDir)', () => { + const client = mockClient({ + textFiles: { + '/cscheid/columns.qmd': 'col', + '/cscheid/crossrefs.qmd': 'xref', + }, + }); + + const zip = exportProjectAsZip(client); + const entries = unzipSync(zip); + + expect(entries['cscheid/columns.qmd']).toBeDefined(); + expect(entries['cscheid/crossrefs.qmd']).toBeDefined(); + // No entry may be absolute — unzip strips those with a warning. + expect(Object.keys(entries).every(k => !k.startsWith('/'))).toBe(true); + }); + + it('nests every entry under the rootDir folder and strips leading slashes', () => { + const client = mockClient({ + textFiles: { + '/cscheid/columns.qmd': 'col', + '/index.qmd': 'root', + }, + binaryFiles: { + '/images/logo.gif': { + content: new Uint8Array([0x47, 0x49, 0x46, 0x38]), + mimeType: 'image/gif', + }, + }, + }); + + const zip = exportProjectAsZip(client, 'Demo-Playground'); + const entries = unzipSync(zip); + + expect(entries['Demo-Playground/cscheid/columns.qmd']).toBeDefined(); + expect(entries['Demo-Playground/index.qmd']).toBeDefined(); + expect(entries['Demo-Playground/images/logo.gif']).toBeDefined(); + // Every entry is under the single top-level folder; none is absolute. + const keys = Object.keys(entries); + expect(keys.every(k => k.startsWith('Demo-Playground/'))).toBe(true); + expect(keys.every(k => !k.startsWith('/'))).toBe(true); + }); + + it('normalizes a rootDir with stray slashes into one clean segment', () => { + const client = mockClient({ + textFiles: { '/index.qmd': 'root' }, + }); + + // Leading/trailing slashes on rootDir must not leak into entry keys. + const zip = exportProjectAsZip(client, '/My Project/'); + const entries = unzipSync(zip); + + const keys = Object.keys(entries); + expect(keys).toEqual(['My-Project/index.qmd']); + expect(keys.every(k => !k.startsWith('/') && !k.includes('//'))).toBe(true); + }); + + it('round-trips through parseProjectZip back to project-relative paths', () => { + const client = mockClient({ + textFiles: { + '/cscheid/columns.qmd': 'col', + '/cscheid/crossrefs.qmd': 'xref', + '/index.qmd': 'root', + }, + }); + + const zip = exportProjectAsZip(client, 'Demo-Playground'); + const parsed = parseProjectZip(zip); + const paths = parsed.map(f => f.path).sort(); + + // The importer strips the common top-level folder, recovering the + // original project-relative paths (leading slash gone on both sides). + expect(paths).toEqual([ + 'cscheid/columns.qmd', + 'cscheid/crossrefs.qmd', + 'index.qmd', + ]); + }); }); diff --git a/ts-packages/quarto-sync-client/src/export-zip.ts b/ts-packages/quarto-sync-client/src/export-zip.ts index ddb8b01a8..d28f7d97a 100644 --- a/ts-packages/quarto-sync-client/src/export-zip.ts +++ b/ts-packages/quarto-sync-client/src/export-zip.ts @@ -6,6 +6,7 @@ */ import { zipSync, strToU8 } from 'fflate'; +import { projectFolderName } from './project-folder-name.js'; import type { SyncClient } from './client.js'; /** @@ -14,28 +15,52 @@ import type { SyncClient } from './client.js'; * Reads every file from the connected SyncClient (text and binary) * and packs them into a ZIP. Text files are encoded as UTF-8. * + * Project paths are stored absolute (leading slash). ZIP entries must be + * *relative* — an absolute entry makes `unzip` emit a "stripped absolute + * path spec" warning and drop the leading slash (GH #147). This function + * therefore always strips leading slashes, and when a `rootDir` is given it + * nests every entry under that single top-level folder (matching the download + * filename), so the archive extracts into one tidy directory. The importer + * (`parseProjectZip`) strips that common folder back off on the way in. + * * @param client - A connected SyncClient instance + * @param rootDir - Optional top-level folder name (typically the project's + * description). Sanitized to a safe single path segment. When omitted or + * blank, entries are packed at the archive root (still relative). * @returns Uint8Array containing the ZIP file bytes * @throws If the client is not connected */ -export function exportProjectAsZip(client: SyncClient): Uint8Array { +export function exportProjectAsZip( + client: SyncClient, + rootDir?: string, +): Uint8Array { if (!client.isConnected()) { throw new Error('SyncClient is not connected'); } + // Sanitize the wrapper folder to a safe single segment. `projectFolderName` + // trims stray leading/trailing separators, so we never emit `//` or an + // absolute prefix. Blank/undefined => no wrapper folder. + const prefix = rootDir && rootDir.trim() ? `${projectFolderName(rootDir)}/` : ''; + const paths = client.getFilePaths(); const files: Record = {}; for (const path of paths) { + // Strip leading slashes so the entry is relative, then nest under prefix. + const relative = path.replace(/^\/+/, ''); + if (relative === '') continue; // guard against a bare "/" path + const key = `${prefix}${relative}`; + if (client.isFileBinary(path)) { const binary = client.getBinaryFileContent(path); if (binary) { - files[path] = binary.content; + files[key] = binary.content; } } else { const text = client.getFileContent(path); if (text !== null) { - files[path] = strToU8(text); + files[key] = strToU8(text); } } } diff --git a/ts-packages/quarto-sync-client/src/index.ts b/ts-packages/quarto-sync-client/src/index.ts index 367d1d12b..d5601e69f 100644 --- a/ts-packages/quarto-sync-client/src/index.ts +++ b/ts-packages/quarto-sync-client/src/index.ts @@ -93,6 +93,7 @@ export { MemoryStorageAdapter } from './storage-adapter.js'; export { computeSHA256 } from './hash.js'; export { exportProjectAsZip } from './export-zip.js'; export { parseProjectZip } from './import-zip.js'; +export { projectFolderName } from './project-folder-name.js'; // Export replay API export { createReplaySession } from './replay.js'; diff --git a/ts-packages/quarto-sync-client/src/project-folder-name.test.ts b/ts-packages/quarto-sync-client/src/project-folder-name.test.ts new file mode 100644 index 000000000..b30d09519 --- /dev/null +++ b/ts-packages/quarto-sync-client/src/project-folder-name.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { projectFolderName } from './project-folder-name.js'; + +describe('projectFolderName', () => { + it('turns spaces into hyphens (existing download-filename behavior)', () => { + expect(projectFolderName('Demo Playground')).toBe('Demo-Playground'); + }); + + it('falls back to "project" for undefined or empty names', () => { + expect(projectFolderName(undefined)).toBe('project'); + expect(projectFolderName('')).toBe('project'); + expect(projectFolderName(' ')).toBe('project'); + }); + + it('collapses path separators into a single safe segment', () => { + expect(projectFolderName('a/b')).toBe('a-b'); + expect(projectFolderName('a\\b')).toBe('a-b'); + }); + + it('replaces Windows-hostile characters rather than preserving them', () => { + const result = projectFolderName('A: B? "C" |E| *F*'); + expect(result).not.toMatch(/[<>:"/\\|?*]/); + // spaces and reserved chars collapse to single hyphens + expect(result).toBe('A-B-C-D-E-F'); + }); + + it('replaces control characters', () => { + // Tab (U+0009) and other C0 control chars must not survive into a path. + const tab = String.fromCharCode(9); + const result = projectFolderName('a' + tab + 'b' + tab + 'c'); + expect(result).toBe('a-b-c'); + }); + + it('strips trailing dots and spaces (illegal on Windows)', () => { + expect(projectFolderName('My Project.')).toBe('My-Project'); + expect(projectFolderName('My Project...')).toBe('My-Project'); + expect(projectFolderName('trailing ')).toBe('trailing'); + }); + + it('trims leading/trailing hyphens produced by surrounding slashes', () => { + expect(projectFolderName('/My Project/')).toBe('My-Project'); + expect(projectFolderName('/leading')).toBe('leading'); + }); + + it('returns a non-empty result even for all-hostile input', () => { + expect(projectFolderName('///')).toBe('project'); + expect(projectFolderName('***')).toBe('project'); + expect(projectFolderName('...')).toBe('project'); + }); +}); diff --git a/ts-packages/quarto-sync-client/src/project-folder-name.ts b/ts-packages/quarto-sync-client/src/project-folder-name.ts new file mode 100644 index 000000000..50f18f3f0 --- /dev/null +++ b/ts-packages/quarto-sync-client/src/project-folder-name.ts @@ -0,0 +1,46 @@ +/** + * Derive a safe single path segment / filename stem from a project's + * human-readable name (its `description`). + * + * Used for two things that must stay in lock-step (see GH #147): + * - the download filename stem of an exported project ZIP, and + * - the top-level folder every entry inside that ZIP is nested under. + * + * The result is safe to use as one path segment on all platforms: spaces, + * path separators, Windows-reserved characters (`< > : " / \ | ? *`) and + * C0 control characters are collapsed to hyphens, and trailing dots/spaces + * (illegal on Windows) are removed. An empty result falls back to + * `"project"`, matching the historical download-filename fallback. + */ + +// Character codes that are unsafe as a single path segment on some platform: +// the Windows-reserved set plus both path separators. (Control chars and the +// space are handled by the `code <= 0x20` check in `isHostile`.) +const RESERVED_CODES = new Set([ + 0x3c, // < + 0x3e, // > + 0x3a, // : + 0x22, // " + 0x2f, // / (forward slash) + 0x5c, // \ (backslash) + 0x7c, // | + 0x3f, // ? + 0x2a, // * +]); + +function isHostile(code: number): boolean { + // code <= 0x20 covers all C0 control chars (0x00–0x1f) and the space (0x20). + return code <= 0x20 || RESERVED_CODES.has(code); +} + +export function projectFolderName(description: string | undefined): string { + let out = ''; + for (const ch of description || '') { + out += isHostile(ch.charCodeAt(0)) ? '-' : ch; + } + const cleaned = out + .replace(/-+/g, '-') // collapse runs of hyphens + .replace(/^-+/, '') // trim leading hyphens (e.g. from a leading slash) + .replace(/[-. ]+$/, ''); // trim trailing hyphen/dot/space + return cleaned || 'project'; +}