mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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:
parent
461a312002
commit
724d071c01
11 changed files with 760 additions and 27 deletions
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
231
apps/daemon/tests/project-file-rename.test.ts
Normal file
231
apps/daemon/tests/project-file-rename.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue