feat: add design file rename support (#894)

* feat(contracts): add project file rename contract

* feat(daemon): add safe project file rename API

* feat(web): support renaming design files

* fix(daemon): handle case-only file renames

* fix(web): prevent rename collisions with pending sketches

* fix(daemon): preserve source names during rename

* test(daemon): cover rename symlink escapes

* fix(daemon): avoid clobbering rename targets

* test(web): align rename tests after rebase

* test(web): align rename tests with latest main
This commit is contained in:
CIoudherd 2026-05-09 21:24:36 +08:00 committed by GitHub
parent 461a312002
commit 724d071c01
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 760 additions and 27 deletions

View file

@ -7,7 +7,7 @@
// All paths flowing in from HTTP handlers are validated against the project
// directory to prevent path traversal — see resolveSafe().
import { lstat, mkdir, readdir, readFile, realpath, rm, stat, unlink, writeFile } from 'node:fs/promises';
import { link, lstat, mkdir, readdir, readFile, realpath, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
import JSZip from 'jszip';
import {
@ -18,6 +18,9 @@ import {
const FORBIDDEN_SEGMENT = /^$|^\.\.?$/;
const RESERVED_PROJECT_FILE_SEGMENTS = new Set(['.live-artifacts']);
export const projectFileRenameTestHooks = {
beforeCommit: null as null | ((paths: { source: string; target: string }) => Promise<void> | void),
};
export function projectDir(projectsRoot, projectId) {
if (!isSafeId(projectId)) throw new Error('invalid project id');
@ -426,6 +429,169 @@ export async function deleteProjectFile(projectsRoot, projectId, name, metadata?
await unlink(file);
}
export async function renameProjectFile(projectsRoot, projectId, fromName, toName, metadata?) {
const dir = resolveProjectDir(projectsRoot, projectId, metadata);
const oldName = validateProjectPath(fromName);
const newName = sanitizePath(toName);
const source = await resolveSafeReal(dir, oldName);
const sourceStat = await stat(source);
if (!sourceStat.isFile()) {
const err = new Error('source is not a regular file');
err.code = 'EISDIR';
throw err;
}
if (oldName === newName) {
const manifest = await readManifestForPath(dir, oldName);
return {
file: {
name: oldName,
path: oldName,
size: sourceStat.size,
mtime: sourceStat.mtimeMs,
kind: kindFor(oldName),
mime: mimeFor(oldName),
artifactKind: manifest?.kind,
artifactManifest: manifest,
},
oldName,
newName: oldName,
};
}
const target = await resolveSafeReal(dir, newName);
const targetPath = source === target ? resolveSafe(dir, newName) : target;
if (source !== target) {
try {
await stat(target);
const err = new Error('target file already exists');
err.code = 'EEXIST';
throw err;
} catch (err) {
if (!err || err.code !== 'ENOENT') throw err;
}
}
const manifestRename = await prepareArtifactManifestRename(dir, oldName, newName);
await mkdir(path.dirname(targetPath), { recursive: true });
await projectFileRenameTestHooks.beforeCommit?.({ source, target: targetPath });
await renameFilePath(source, targetPath, { noOverwrite: true });
await commitArtifactManifestRename(manifestRename, newName);
const st = await stat(targetPath);
const manifest = await readManifestForPath(dir, newName);
return {
file: {
name: newName,
path: newName,
size: st.size,
mtime: st.mtimeMs,
kind: kindFor(newName),
mime: mimeFor(newName),
artifactKind: manifest?.kind,
artifactManifest: manifest,
},
oldName,
newName,
};
}
async function renameFilePath(source, target, opts = {}) {
const { noOverwrite = false } = opts;
if (source === target) return;
const temp = await uniqueRenameTempPath(source);
await rename(source, temp);
try {
if (noOverwrite) {
await link(temp, target);
try {
await unlink(temp);
} catch {
// Preserve the target file even if cleanup of the temp link fails.
}
} else {
await rename(temp, target);
}
} catch (err) {
try {
await rename(temp, source);
} catch {
// Preserve the original rename error even if restoring the source path fails.
}
throw err;
}
}
async function uniqueRenameTempPath(source) {
const dir = path.dirname(source);
const base = path.basename(source);
for (let i = 0; i < 10; i++) {
const temp = path.join(dir, `.od-rename-${process.pid}-${Date.now()}-${i}-${base}.tmp`);
try {
await stat(temp);
} catch (err) {
if (err && err.code === 'ENOENT') return temp;
throw err;
}
}
const err = new Error('could not allocate temporary rename path');
err.code = 'EEXIST';
throw err;
}
async function prepareArtifactManifestRename(dir, oldName, newName) {
const oldManifestName = artifactManifestNameFor(oldName);
const oldManifestPath = await resolveSafeReal(dir, oldManifestName).catch((err) => {
if (err && err.code === 'ENOENT') return null;
throw err;
});
if (!oldManifestPath) return null;
let raw = null;
try {
raw = await readFile(oldManifestPath, 'utf8');
} catch (err) {
if (err && err.code === 'ENOENT') return null;
throw err;
}
const newManifestName = artifactManifestNameFor(newName);
const newManifestPath = await resolveSafeReal(dir, newManifestName);
const targetManifestPath = oldManifestPath === newManifestPath
? resolveSafe(dir, newManifestName)
: newManifestPath;
if (oldManifestPath !== newManifestPath) {
try {
await stat(newManifestPath);
const err = new Error('target artifact manifest already exists');
err.code = 'EEXIST';
throw err;
} catch (err) {
if (!err || err.code !== 'ENOENT') throw err;
}
}
return { oldManifestPath, newManifestPath: targetManifestPath, raw };
}
async function commitArtifactManifestRename(manifestRename, newName) {
if (!manifestRename) return;
const { oldManifestPath, newManifestPath, raw } = manifestRename;
await mkdir(path.dirname(newManifestPath), { recursive: true });
const parsed = parseManifest(raw);
if (parsed) {
const validated = validateArtifactManifestInput(parsed, newName);
if (validated.ok && validated.value) {
await writeFile(oldManifestPath, JSON.stringify(validated.value, null, 2));
await renameFilePath(oldManifestPath, newManifestPath, { noOverwrite: true });
return;
}
}
await renameFilePath(oldManifestPath, newManifestPath, { noOverwrite: true });
}
export async function removeProjectDir(projectsRoot, projectId) {
const dir = projectDir(projectsRoot, projectId);
await rm(dir, { recursive: true, force: true });

View file

@ -134,6 +134,7 @@ import {
mimeFor,
projectDir,
readProjectFile,
renameProjectFile,
removeProjectDir,
sanitizeName,
searchProjectFiles,
@ -4497,6 +4498,35 @@ export async function startServer({
},
);
app.post('/api/projects/:id/files/rename', async (req, res) => {
try {
const { from, to } = req.body || {};
if (typeof from !== 'string' || typeof to !== 'string') {
return sendApiError(res, 400, 'BAD_REQUEST', 'from and to required');
}
const project = getProject(db, req.params.id);
const result = await renameProjectFile(
PROJECTS_DIR,
req.params.id,
from,
to,
project?.metadata,
);
/** @type {import('@open-design/contracts').RenameProjectFileResponse} */
const body = result;
res.json(body);
} catch (err) {
const code = err && err.code;
if (code === 'ENOENT') {
return sendApiError(res, 404, 'FILE_NOT_FOUND', 'file not found');
}
if (code === 'EEXIST') {
return sendApiError(res, 409, 'FILE_EXISTS', 'target file already exists');
}
sendApiError(res, 400, 'BAD_REQUEST', String(err?.message || err));
}
});
app.delete('/api/projects/:id/files/:name', async (req, res) => {
try {
const delProject = getProject(db, req.params.id);

View file

@ -0,0 +1,231 @@
import type http from 'node:http';
import { mkdtempSync, rmSync } from 'node:fs';
import { mkdir, readFile, stat, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { projectFileRenameTestHooks } from '../src/projects.js';
import { startServer } from '../src/server.js';
describe('project file rename route', () => {
let server: http.Server;
let baseUrl: string;
const tempDirs: string[] = [];
beforeAll(async () => {
const started = (await startServer({ port: 0, returnServer: true })) as {
url: string;
server: http.Server;
};
baseUrl = started.url;
server = started.server;
});
afterEach(() => {
projectFileRenameTestHooks.beforeCommit = null;
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
afterAll(() => new Promise<void>((resolve) => server.close(() => resolve())));
async function createProject() {
const id = `rename-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const resp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, name: id }),
});
expect(resp.status).toBe(200);
const body = (await resp.json()) as { project: { id: string } };
return body.project.id;
}
async function writeText(projectId: string, name: string, content = 'hello') {
const resp = await fetch(`${baseUrl}/api/projects/${projectId}/files`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, content }),
});
expect(resp.status).toBe(200);
}
async function renameFile(projectId: string, from: string, to: string) {
return fetch(`${baseUrl}/api/projects/${projectId}/files/rename`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from, to }),
});
}
async function importFolder(folder: string) {
const importResp = await fetch(`${baseUrl}/api/import/folder`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ baseDir: folder }),
});
expect(importResp.status).toBe(200);
const { project } = (await importResp.json()) as { project: { id: string } };
return project.id;
}
it('renames a text file and preserves non-ASCII target names', async () => {
const projectId = await createProject();
await writeText(projectId, 'paste-1.txt', 'body');
const resp = await renameFile(projectId, 'paste-1.txt', '需求说明.txt');
expect(resp.status).toBe(200);
const body = (await resp.json()) as { oldName: string; newName: string; file: { name: string } };
expect(body.oldName).toBe('paste-1.txt');
expect(body.newName).toBe('需求说明.txt');
expect(body.file.name).toBe('需求说明.txt');
const renamed = await fetch(`${baseUrl}/api/projects/${projectId}/raw/${encodeURIComponent('需求说明.txt')}`);
expect(renamed.status).toBe(200);
expect(await renamed.text()).toBe('body');
});
it('renames case-only filename changes instead of treating them as no-ops', async () => {
const projectId = await createProject();
await writeText(projectId, 'paste-1.txt', 'body');
const resp = await renameFile(projectId, 'paste-1.txt', 'Paste-1.txt');
expect(resp.status).toBe(200);
const body = (await resp.json()) as { oldName: string; newName: string; file: { name: string } };
expect(body.oldName).toBe('paste-1.txt');
expect(body.newName).toBe('Paste-1.txt');
expect(body.file.name).toBe('Paste-1.txt');
const filesResp = await fetch(`${baseUrl}/api/projects/${projectId}/files`);
const filesBody = (await filesResp.json()) as { files: Array<{ name: string }> };
expect(filesBody.files.some((file) => file.name === 'Paste-1.txt')).toBe(true);
expect(filesBody.files.some((file) => file.name === 'paste-1.txt')).toBe(false);
});
it('rejects target file conflicts without overwriting', async () => {
const projectId = await createProject();
await writeText(projectId, 'a.txt', 'first');
await writeText(projectId, 'b.txt', 'second');
const resp = await renameFile(projectId, 'a.txt', 'b.txt');
expect(resp.status).toBe(409);
const existing = await fetch(`${baseUrl}/api/projects/${projectId}/raw/b.txt`);
expect(await existing.text()).toBe('second');
});
it('does not overwrite a target file created during rename', async () => {
const folder = mkdtempSync(path.join(tmpdir(), 'od-rename-race-'));
tempDirs.push(folder);
await writeFile(path.join(folder, 'source.txt'), 'source');
const projectId = await importFolder(folder);
projectFileRenameTestHooks.beforeCommit = async ({ target }) => {
await writeFile(target, 'concurrent');
};
const resp = await renameFile(projectId, 'source.txt', 'target.txt');
expect(resp.status).toBe(409);
expect(await readFile(path.join(folder, 'source.txt'), 'utf8')).toBe('source');
expect(await readFile(path.join(folder, 'target.txt'), 'utf8')).toBe('concurrent');
});
it('rejects invalid and escaping paths', async () => {
const projectId = await createProject();
await writeText(projectId, 'a.txt');
expect((await renameFile(projectId, 'a.txt', '../outside.txt')).status).toBe(400);
expect((await renameFile(projectId, 'a.txt', '/tmp/outside.txt')).status).toBe(400);
expect((await renameFile(projectId, 'a.txt', '.live-artifacts/x.txt')).status).toBe(400);
});
it('returns 404 when the source file is missing', async () => {
const projectId = await createProject();
const resp = await renameFile(projectId, 'missing.txt', 'next.txt');
expect(resp.status).toBe(404);
});
it('renames artifact manifests alongside their entry file', async () => {
const projectId = await createProject();
const resp = await fetch(`${baseUrl}/api/projects/${projectId}/files`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'old.html',
content: '<!doctype html>',
artifactManifest: {
kind: 'html',
renderer: 'html',
exports: ['html'],
title: 'Old',
},
}),
});
expect(resp.status).toBe(200);
const renamed = await renameFile(projectId, 'old.html', 'new.html');
expect(renamed.status).toBe(200);
const filesResp = await fetch(`${baseUrl}/api/projects/${projectId}/files`);
const filesBody = (await filesResp.json()) as {
files: Array<{ name: string; artifactManifest?: { entry?: string } }>;
};
expect(filesBody.files.find((file) => file.name === 'new.html')?.artifactManifest?.entry).toBe('new.html');
});
it('renames files in imported folders on disk', async () => {
const folder = mkdtempSync(path.join(tmpdir(), 'od-rename-import-'));
tempDirs.push(folder);
await writeFile(path.join(folder, 'note.txt'), 'imported');
const projectId = await importFolder(folder);
const renamed = await renameFile(projectId, 'note.txt', 'renamed-note.txt');
expect(renamed.status).toBe(200);
await expect(stat(path.join(folder, 'note.txt'))).rejects.toMatchObject({ code: 'ENOENT' });
expect(await readFile(path.join(folder, 'renamed-note.txt'), 'utf8')).toBe('imported');
});
it('renames imported folder files whose existing names contain spaces', async () => {
const folder = mkdtempSync(path.join(tmpdir(), 'od-rename-import-spaces-'));
tempDirs.push(folder);
await writeFile(path.join(folder, 'my note.txt'), 'imported');
const projectId = await importFolder(folder);
const renamed = await renameFile(projectId, 'my note.txt', 'renamed-note.txt');
expect(renamed.status).toBe(200);
await expect(stat(path.join(folder, 'my note.txt'))).rejects.toMatchObject({ code: 'ENOENT' });
expect(await readFile(path.join(folder, 'renamed-note.txt'), 'utf8')).toBe('imported');
});
it('rejects source paths that escape through a symlinked directory', async () => {
const folder = mkdtempSync(path.join(tmpdir(), 'od-rename-symlink-source-'));
const outside = mkdtempSync(path.join(tmpdir(), 'od-rename-outside-source-'));
tempDirs.push(folder, outside);
await writeFile(path.join(outside, 'secret.txt'), 'outside');
await symlink(outside, path.join(folder, 'linked'), 'dir');
const projectId = await importFolder(folder);
const renamed = await renameFile(projectId, 'linked/secret.txt', 'renamed.txt');
expect(renamed.status).toBe(400);
expect(await readFile(path.join(outside, 'secret.txt'), 'utf8')).toBe('outside');
await expect(stat(path.join(folder, 'renamed.txt'))).rejects.toMatchObject({ code: 'ENOENT' });
});
it('rejects target paths that escape through a symlinked directory', async () => {
const folder = mkdtempSync(path.join(tmpdir(), 'od-rename-symlink-target-'));
const outside = mkdtempSync(path.join(tmpdir(), 'od-rename-outside-target-'));
tempDirs.push(folder, outside);
await writeFile(path.join(folder, 'note.txt'), 'inside');
await mkdir(path.join(outside, 'sink'));
await symlink(path.join(outside, 'sink'), path.join(folder, 'linked'), 'dir');
const projectId = await importFolder(folder);
const renamed = await renameFile(projectId, 'note.txt', 'linked/note.txt');
expect(renamed.status).toBe(400);
expect(await readFile(path.join(folder, 'note.txt'), 'utf8')).toBe('inside');
await expect(stat(path.join(outside, 'sink', 'note.txt'))).rejects.toMatchObject({ code: 'ENOENT' });
});
});

View file

@ -15,6 +15,7 @@ interface Props {
onRefreshFiles: () => Promise<void> | void;
onOpenFile: (name: string) => void;
onOpenLiveArtifact: (tabId: LiveArtifactWorkspaceEntry['tabId']) => void;
onRenameFile: (from: string, to: string) => Promise<ProjectFile | null> | ProjectFile | null;
onDeleteFile: (name: string) => void;
onDeleteFiles: (names: string[]) => Promise<void> | void;
onUpload: () => void;
@ -33,6 +34,7 @@ export function DesignFilesPanel({
onRefreshFiles,
onOpenFile,
onOpenLiveArtifact,
onRenameFile,
onDeleteFile,
onDeleteFiles,
onUpload,
@ -46,7 +48,7 @@ export function DesignFilesPanel({
const dragDepthRef = useRef(0);
const [hover, setHover] = useState<string | null>(null);
const [menuPos, setMenuPos] = useState<{ name: string; top: number; left: number } | null>(null);
const MENU_ESTIMATED_HEIGHT = 115;
const MENU_ESTIMATED_HEIGHT = 145;
const MENU_SAFE_PADDING = 8;
const [preview, setPreview] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
@ -54,6 +56,7 @@ export function DesignFilesPanel({
const [sortDir, setSortDir] = useState<SortDir>('desc');
const lastKeyPress = useRef<Map<string, number>>(new Map());
const [deleting, setDeleting] = useState(false);
const [renaming, setRenaming] = useState<{ name: string; draft: string; saving: boolean } | null>(null);
const sortedFiles = useMemo(() => {
return [...files].sort((a, b) => {
@ -197,6 +200,37 @@ export function DesignFilesPanel({
setMenuPos({ name, top, left });
}
function startRename(name: string) {
setMenuPos(null);
setPreview(name);
setRenaming({ name, draft: name, saving: false });
}
async function commitRename(name: string, draft: string) {
const nextName = draft.trim();
if (!nextName || nextName === name) {
setRenaming(null);
return;
}
setRenaming({ name, draft, saving: true });
try {
const renamed = await onRenameFile(name, nextName);
if (!renamed) throw new Error('Rename failed');
setPreview((curr) => (curr === name ? renamed.name : curr));
setSelected((prev) => {
if (!prev.has(name)) return prev;
const next = new Set(prev);
next.delete(name);
next.add(renamed.name);
return next;
});
setRenaming(null);
} catch (err) {
alert(err instanceof Error ? err.message : String(err));
setRenaming({ name, draft, saving: false });
}
}
async function handleBatchDelete() {
if (deleting) return;
const fileList = [...selected];
@ -468,6 +502,7 @@ export function DesignFilesPanel({
{pageFiles.map((f) => {
const active = preview === f.name;
const isHovered = hover === f.name;
const renameState = renaming?.name === f.name ? renaming : null;
return (
<tr
key={f.name}
@ -508,33 +543,66 @@ export function DesignFilesPanel({
</td>
<td
className="df-cell-name df-cell-openable"
onClick={() => setPreview(f.name)}
onDoubleClick={() => onOpenFile(f.name)}
onClick={() => {
if (!renameState) setPreview(f.name);
}}
onDoubleClick={() => {
if (!renameState) onOpenFile(f.name);
}}
>
<button
type="button"
className="df-row-name-btn"
onClick={() => setPreview(f.name)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const now = Date.now();
const last = lastKeyPress.current.get(f.name) ?? 0;
if (now - last < 300) {
lastKeyPress.current.delete(f.name);
onOpenFile(f.name);
} else {
lastKeyPress.current.set(f.name, now);
setPreview(f.name);
}
{renameState ? (
<input
autoFocus
className="df-rename-input"
value={renameState.draft}
disabled={renameState.saving}
onChange={(e) =>
setRenaming({ ...renameState, draft: e.target.value })
}
}}
>
<span className="df-row-name-wrap">
<span className="df-row-name">{f.name}</span>
<span className="df-row-sub">{humanBytes(f.size)}</span>
</span>
</button>
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
onBlur={(e) => {
if (e.currentTarget.dataset.skipRenameCommit === '1') return;
void commitRename(f.name, renameState.draft);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.currentTarget.dataset.skipRenameCommit = '1';
void commitRename(f.name, renameState.draft);
} else if (e.key === 'Escape') {
e.preventDefault();
e.currentTarget.dataset.skipRenameCommit = '1';
setRenaming(null);
}
}}
/>
) : (
<button
type="button"
className="df-row-name-btn"
onClick={() => setPreview(f.name)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const now = Date.now();
const last = lastKeyPress.current.get(f.name) ?? 0;
if (now - last < 300) {
lastKeyPress.current.delete(f.name);
onOpenFile(f.name);
} else {
lastKeyPress.current.set(f.name, now);
setPreview(f.name);
}
}
}}
>
<span className="df-row-name-wrap">
<span className="df-row-name">{f.name}</span>
<span className="df-row-sub">{humanBytes(f.size)}</span>
</span>
</button>
)}
</td>
<td
className="df-cell-kind df-cell-openable"
@ -670,6 +738,15 @@ export function DesignFilesPanel({
>
{t('designFiles.openInTab')}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
startRename(menuPos.name);
}}
>
{t('common.rename')}
</button>
<a
href={projectFileUrl(projectId, menuPos.name)}
download={menuPos.name}

View file

@ -9,6 +9,7 @@ import { useT } from '../i18n';
import {
deleteProjectFile,
fetchProjectFileText,
renameProjectFile,
uploadProjectFiles,
writeProjectTextFile,
} from '../providers/registry';
@ -404,6 +405,37 @@ export function FileWorkspace({
}
}
async function handleRename(oldName: string, nextName: string): Promise<ProjectFile | null> {
const hasPendingSketchConflict = Object.entries(sketches).some(
([name, sketch]) => !sketch.persisted && sameFileName(name, nextName),
);
if (nextName !== oldName && hasPendingSketchConflict) {
throw new Error(
`A pending sketch named "${nextName}" is already open. Save or close it before renaming.`,
);
}
const result = await renameProjectFile(projectId, oldName, nextName);
const renamed = result.file;
await onRefreshFiles();
const nextTabs = persistedTabs.map((name) => (name === oldName ? renamed.name : name));
const nextActive = tabsState.active === oldName ? renamed.name : tabsState.active;
onTabsStateChange({ tabs: nextTabs, active: nextActive });
if (activeTab === oldName) setActiveTab(renamed.name);
setSketches((curr) => {
const entry = curr[oldName];
if (!entry) return curr;
const next = { ...curr };
delete next[oldName];
next[renamed.name] = entry;
return next;
});
return renamed;
}
function startNewSketch() {
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const name = `sketch-${stamp}.sketch.json`;
@ -650,6 +682,7 @@ export function FileWorkspace({
onRefreshFiles={onRefreshFiles}
onOpenFile={openFile}
onOpenLiveArtifact={(tabId) => openFile(tabId)}
onRenameFile={handleRename}
onDeleteFile={(name) => void handleDelete(name)}
onDeleteFiles={handleDeleteMany}
onUpload={() => fileInputRef.current?.click()}
@ -892,6 +925,10 @@ function isSketchName(name: string): boolean {
return name.endsWith('.sketch.json');
}
function sameFileName(a: string, b: string): boolean {
return a === b || a.toLocaleLowerCase() === b.toLocaleLowerCase();
}
function isLiveArtifactImplementationPath(name: string): boolean {
if (name === '.live-artifacts') return true;
if (!name.startsWith('.live-artifacts/')) return false;

View file

@ -7462,6 +7462,18 @@ button.connector-action.is-loading {
overflow: hidden;
text-overflow: ellipsis;
}
.df-rename-input {
width: 100%;
min-width: 0;
border: 1px solid var(--accent);
border-radius: 4px;
background: var(--bg-panel);
color: var(--text);
font: inherit;
font-size: 13px;
line-height: 1.3;
padding: 3px 6px;
}
.df-row-sub {
font-size: 11px;
color: var(--text-muted);

View file

@ -32,6 +32,7 @@ import type {
PromptTemplateDetail,
PromptTemplateSummary,
ProjectFile,
RenameProjectFileResponse,
SkillDetail,
SkillSummary,
UpdateDeployConfigRequest,
@ -1196,6 +1197,23 @@ export async function deleteProjectFile(
}
}
export async function renameProjectFile(
projectId: string,
from: string,
to: string,
): Promise<RenameProjectFileResponse> {
const resp = await fetch(`/api/projects/${encodeURIComponent(projectId)}/files/rename`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from, to }),
});
if (!resp.ok) {
const errorBody = await readApiErrorBody(resp);
throw new Error(errorBody.message);
}
return (await resp.json()) as RenameProjectFileResponse;
}
export async function openFolderDialog(): Promise<string | null> {
try {
const resp = await fetch('/api/dialog/open-folder', { method: 'POST' });

View file

@ -45,6 +45,7 @@ import type {
ProjectKind,
ProjectMetadata,
ProjectTemplate,
RenameProjectFileResponse,
CodexPetSummary,
CodexPetsResponse,
SyncCommunityPetsRequest,
@ -425,6 +426,7 @@ export type {
ProjectKind,
ProjectMetadata,
ProjectTemplate,
RenameProjectFileResponse,
ProviderTestRequest,
CodexPetSummary,
CodexPetsResponse,

View file

@ -42,6 +42,7 @@ function renderPanel(files: ProjectFile[]) {
onRefreshFiles={vi.fn()}
onOpenFile={onOpenFile}
onOpenLiveArtifact={vi.fn()}
onRenameFile={vi.fn()}
onDeleteFile={vi.fn()}
onDeleteFiles={onDeleteFiles}
onUpload={vi.fn()}

View file

@ -21,6 +21,9 @@ afterEach(() => {
}
host?.remove();
host = null;
vi.restoreAllMocks();
vi.useRealTimers();
vi.unstubAllGlobals();
});
function workspaceFile(name: string): ProjectFile {
@ -95,6 +98,12 @@ function stubTabRect(tab: HTMLElement, left = 0, width = 100) {
}));
}
function changeInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
setter?.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
describe('FileWorkspace upload input', () => {
it('keeps the Design Files picker aligned with drag-and-drop file support', () => {
const markup = renderToStaticMarkup(
@ -176,6 +185,145 @@ describe('FileWorkspace upload input', () => {
});
});
describe('FileWorkspace design file rename', () => {
it('renames from the Design Files row menu and replaces persisted tabs', async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith('/api/projects/project-1/files/rename') && init?.method === 'POST') {
return new Response(
JSON.stringify({
file: workspaceFile('resume-notes.txt'),
oldName: 'paste-1.txt',
newName: 'resume-notes.txt',
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}
return new Response('', { status: 200 });
});
vi.stubGlobal('fetch', fetchMock);
const onTabsStateChange = vi.fn();
const onRefreshFiles = vi.fn();
const container = renderWorkspace(
<FileWorkspace
projectId="project-1"
files={[workspaceFile('paste-1.txt'), workspaceFile('index.html')]}
liveArtifacts={[]}
onRefreshFiles={onRefreshFiles}
isDeck={false}
tabsState={{ tabs: ['paste-1.txt', 'index.html'], active: 'paste-1.txt' }}
onTabsStateChange={onTabsStateChange}
/>,
);
const designFilesTab = container.querySelector<HTMLElement>('[data-testid="design-files-tab"]');
if (!designFilesTab) throw new Error('Could not find design files tab');
act(() => designFilesTab.click());
const menuButton = container.querySelector<HTMLElement>('[data-testid="design-file-menu-paste-1.txt"]');
if (!menuButton) throw new Error('Could not find design file menu');
act(() => menuButton.click());
const renameButton = Array.from(container.querySelectorAll<HTMLButtonElement>('button'))
.find((button) => button.textContent === 'Rename');
if (!renameButton) throw new Error('Could not find rename command');
act(() => renameButton.click());
const input = container.querySelector<HTMLInputElement>('.df-rename-input');
if (!input) throw new Error('Could not find rename input');
act(() => {
changeInputValue(input, 'resume-notes.txt');
});
await act(async () => {
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
});
expect(fetchMock).toHaveBeenCalledWith(
'/api/projects/project-1/files/rename',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ from: 'paste-1.txt', to: 'resume-notes.txt' }),
}),
);
expect(onTabsStateChange).toHaveBeenLastCalledWith({
tabs: ['resume-notes.txt', 'index.html'],
active: 'resume-notes.txt',
});
expect(onRefreshFiles).toHaveBeenCalledTimes(1);
});
it('rejects renaming a persisted file over an open pending sketch tab', async () => {
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
new Response('', { status: 200 }),
);
const alertMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('alert', alertMock);
vi.stubGlobal('ResizeObserver', class {
observe() {}
unobserve() {}
disconnect() {}
});
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
const onTabsStateChange = vi.fn();
const container = renderWorkspace(
<FileWorkspace
projectId="project-1"
files={[workspaceFile('paste-1.txt')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: ['paste-1.txt'], active: 'paste-1.txt' }}
onTabsStateChange={onTabsStateChange}
/>,
);
const designFilesTab = container.querySelector<HTMLElement>('[data-testid="design-files-tab"]');
if (!designFilesTab) throw new Error('Could not find design files tab');
act(() => designFilesTab.click());
const newSketchButton = Array.from(container.querySelectorAll<HTMLButtonElement>('button'))
.find((button) => button.textContent === 'New sketch');
if (!newSketchButton) throw new Error('Could not find new sketch command');
act(() => newSketchButton.click());
const pendingSketchTab = Array.from(container.querySelectorAll<HTMLElement>('[role="tab"]')).find((tab) =>
tab.textContent?.includes('.sketch.json'),
);
if (!pendingSketchTab) throw new Error('Could not find pending sketch tab');
const pendingSketchName = pendingSketchTab.textContent!.replace(' •', '');
act(() => designFilesTab.click());
const menuButton = container.querySelector<HTMLElement>('[data-testid="design-file-menu-paste-1.txt"]');
if (!menuButton) throw new Error('Could not find file menu');
act(() => menuButton.click());
const renameButton = Array.from(container.querySelectorAll<HTMLButtonElement>('button'))
.find((button) => button.textContent === 'Rename');
if (!renameButton) throw new Error('Could not find rename command');
act(() => renameButton.click());
const input = container.querySelector<HTMLInputElement>('.df-rename-input');
if (!input) throw new Error('Could not find rename input');
act(() => {
changeInputValue(input, pendingSketchName);
});
await act(async () => {
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
});
expect(alertMock).toHaveBeenCalledWith(
`A pending sketch named "${pendingSketchName}" is already open. Save or close it before renaming.`,
);
const renameCalls = fetchMock.mock.calls.filter(([input]) =>
String(input).endsWith('/api/projects/project-1/files/rename'),
);
expect(renameCalls).toHaveLength(0);
expect(onTabsStateChange).not.toHaveBeenCalled();
expect(pendingSketchTab.textContent).toContain(pendingSketchName);
});
});
describe('FileWorkspace tab reordering', () => {
it('persists a dragged file tab before the tab it is dropped on', () => {
const onTabsStateChange = vi.fn();

View file

@ -38,3 +38,14 @@ export interface ProjectFileResponse {
export interface UploadProjectFilesResponse extends ProjectFilesResponse {}
export interface DeleteProjectFileResponse extends OkResponse {}
export interface RenameProjectFileRequest {
from: string;
to: string;
}
export interface RenameProjectFileResponse {
file: ProjectFile;
oldName: string;
newName: string;
}