Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file not shown.
4 changes: 4 additions & 0 deletions hub-client/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
114 changes: 114 additions & 0 deletions hub-client/src/components/tabs/ProjectTab.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof vi.spyOn>;

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(
<ProjectTab
project={makeProject('Demo Playground')}
onChooseNewProject={() => {}}
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(
<ProjectTab
project={makeProject('Demo: Playground?')}
onChooseNewProject={() => {}}
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(
<ProjectTab
project={makeProject('')}
onChooseNewProject={() => {}}
onExportZip={onExportZip}
/>,
);

fireEvent.click(screen.getByText('Export ZIP'));

expect(onExportZip).toHaveBeenCalledWith('project');
expect(clickedDownloadNames).toEqual(['project.zip']);
});
});
15 changes: 12 additions & 3 deletions hub-client/src/components/tabs/ProjectTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
10 changes: 7 additions & 3 deletions ts-packages/preview-runtime/src/automergeSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down
82 changes: 82 additions & 0 deletions ts-packages/quarto-sync-client/src/export-zip.test.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down Expand Up @@ -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',
]);
});
});
31 changes: 28 additions & 3 deletions ts-packages/quarto-sync-client/src/export-zip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import { zipSync, strToU8 } from 'fflate';
import { projectFolderName } from './project-folder-name.js';
import type { SyncClient } from './client.js';

/**
Expand All @@ -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<string, Uint8Array> = {};

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);
}
}
}
Expand Down
1 change: 1 addition & 0 deletions ts-packages/quarto-sync-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
50 changes: 50 additions & 0 deletions ts-packages/quarto-sync-client/src/project-folder-name.test.ts
Original file line number Diff line number Diff line change
@@ -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" <D> |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');
});
});
Loading
Loading