feat(files): delete a file from the Files panel

A delete button on each file row of the Files panel (tree and search
results) and a DELETE /api/sessions/:id/files?path= route that removes one
regular file from the session workspace, refused for remote (SSH)
sessions and behind the same sensitive-path, attachment-guard and .git
checks as the edit route.

Salvaged from #529.
This commit is contained in:
shenlvkang-collab authored and Codeman maintainer committed 2026-11-09 03:09:46 +01:00
1 parent f62c1f7e2f
commit dfe1af2e5f
3 files changed
+86

No files matched your search

+26
View File
@@ -3799,6 +3799,9 @@ Object.assign(CodemanApp.prototype, {
const downloadBtn = !isDir
? `<a class="file-tree-download" href="${escapeHtml(CodemanBase.url(`/api/sessions/${encodeURIComponent(owner)}/file-raw?path=${encodeURIComponent(node.path)}&download=true`))}" title="Download" onclick="event.stopPropagation()">&#x2B07;</a>`
: '';
const deleteBtn = !isDir
? `<button class="file-tree-delete" title="Delete" aria-label="Delete ${escapeHtml(node.name)}" onclick="event.stopPropagation(); app.deleteFileFromBrowser(decodeURIComponent('${encodeURIComponent(owner)}'), decodeURIComponent('${encodeURIComponent(node.path)}'))">&times;</button>`
: '';
html.push(`
<div class="file-tree-item" data-path="${escapeHtml(node.path)}" data-type="${escapeHtml(node.type)}" data-depth="${depth}">
@@ -3807,6 +3810,7 @@ Object.assign(CodemanApp.prototype, {
<span class="${nameClass}">${escapeHtml(node.name)}</span>
${sizeStr}
${downloadBtn}
${deleteBtn}
</div>
`);
@@ -3981,6 +3985,9 @@ Object.assign(CodemanApp.prototype, {
const downloadBtn = !isDir
? `<a class="file-tree-download" href="${escapeHtml(CodemanBase.url(`/api/sessions/${ownerPath}/file-raw?path=${encodeURIComponent(match.path)}&download=true`))}" title="Download" onclick="event.stopPropagation()">&#x2B07;</a>`
: '';
const deleteBtn = !isDir
? `<button class="file-tree-delete" title="Delete" aria-label="Delete ${escapeHtml(match.name)}" onclick="event.stopPropagation(); app.deleteFileFromBrowser(decodeURIComponent('${encodeURIComponent(ownerSessionId)}'), decodeURIComponent('${encodeURIComponent(match.path)}'))">&times;</button>`
: '';
return `
<div class="file-tree-item" data-path="${escapeHtml(match.path)}" data-type="${escapeHtml(match.type)}" data-owner="${escapeHtml(ownerSessionId)}">
<span class="file-tree-expand"></span>
@@ -3988,6 +3995,7 @@ Object.assign(CodemanApp.prototype, {
<span class="${nameClass}">${escapeHtml(match.name)}</span>
${sizeStr}
${downloadBtn}
${deleteBtn}
</div>
`;
})
@@ -4012,6 +4020,24 @@ Object.assign(CodemanApp.prototype, {
}
},
async deleteFileFromBrowser(sessionId, filePath) {
if (!sessionId || !filePath) return;
if (!window.confirm(`Delete ${filePath}?`)) return;
try {
const response = await fetch(
`/api/sessions/${encodeURIComponent(sessionId)}/files?path=${encodeURIComponent(filePath)}`,
{ method: 'DELETE' },
);
const result = await response.json().catch(() => null);
if (!response.ok || !result?.success) throw new Error(result?.error || 'Delete failed');
this.showToast(`Deleted: ${filePath}`, 'success');
await this.loadFileBrowser(sessionId, { force: true });
} catch (error) {
console.error('File browser delete failed:', error);
this.showToast(error?.message || 'Delete failed', 'error');
}
},
_findFileBrowserDirectory(nodes, targetPath, ancestors = []) {
if (!Array.isArray(nodes)) return null;
for (const node of nodes) {
+18
View File
@@ -12386,6 +12386,24 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
.file-tree-download:hover {
color: #4fc3f7;
}
.file-tree-delete {
display: none;
margin-left: 2px;
padding: 0 4px;
border: 0;
background: transparent;
color: #888;
font-size: 14px;
line-height: 1;
cursor: pointer;
flex-shrink: 0;
}
.file-tree-item:hover .file-tree-delete {
display: inline;
}
.file-tree-delete:hover {
color: #ef5350;
}
/* File tree indent levels */
.file-tree-item[data-depth="0"] { padding-left: 0.5rem; }
+42
View File
@@ -2000,6 +2000,48 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
};
});
// Delete a regular file from the current session workspace. The same
// confinement and sensitive-path checks used by previews apply here.
app.delete('/api/sessions/:id/files', async (req, reply) => {
const { id } = req.params as { id: string };
const { path: filePath } = req.query as { path?: string };
const session = findSessionOrFail(ctx, id, req);
if (session.remote) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Deleting is not supported for remote (SSH) sessions');
}
if (!filePath) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter');
}
const validated = validateSessionFilePath(session.workingDir, filePath);
if (!validated) {
reply.code(404);
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found');
}
const { resolvedPath, relativePath } = validated;
const guard = await loadAttachmentGuardConfig();
if (
isSensitivePath(resolvedPath) ||
isBlockedAttachmentPath(resolvedPath, guard.blockedTrees) ||
isDeniedEditRelativePath(relativePath)
) {
reply.code(403);
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Deleting this file is blocked');
}
const stat = await fs.lstat(resolvedPath).catch(() => undefined);
if (!stat) {
reply.code(404);
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found');
}
if (!stat.isFile()) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Only regular files can be deleted');
}
await fs.unlink(resolvedPath);
return { success: true, data: { path: filePath } };
});
// Get file content for preview (File Browser)
app.get('/api/sessions/:id/file-content', async (req) => {
const { id } = req.params as { id: string };