feat(KB-175): move file browser to header with workspace support
- Add workspace file APIs and hooks for session-scoped file browsing - Create WorkspaceSelector component for switching between workspaces - Update FileBrowserModal with workspace mode and improved UX - Add files button to header with workspace-aware file browsing - Add session files indicator to task cards for quick file access - Include comprehensive tests for all new components and hooks
This commit is contained in:
@@ -81,6 +81,8 @@ const TEXT_EXTENSIONS = new Set([
|
||||
".lock", ".log",
|
||||
]);
|
||||
|
||||
export type WorkspaceId = "project" | string;
|
||||
|
||||
/**
|
||||
* Get the base path for a task's files.
|
||||
* Returns the worktree path if it exists, otherwise the task directory.
|
||||
@@ -111,6 +113,20 @@ function getProjectBasePath(store: TaskStore): string {
|
||||
return resolve(store.getRootDir());
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a workspace identifier to a filesystem base path.
|
||||
*
|
||||
* - "project" maps to the dashboard/project root
|
||||
* - any other value is treated as a task ID and resolved to that task's worktree/task directory
|
||||
*/
|
||||
async function getWorkspaceBasePath(store: TaskStore, workspace: WorkspaceId): Promise<string> {
|
||||
if (workspace === "project") {
|
||||
return getProjectBasePath(store);
|
||||
}
|
||||
|
||||
return getTaskBasePath(store, workspace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and resolve a file path to ensure it stays within the allowed directory.
|
||||
* Prevents directory traversal attacks.
|
||||
@@ -149,22 +165,8 @@ function validatePath(basePath: string, filePath: string): string {
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* List files in a task directory or subdirectory.
|
||||
*
|
||||
* @param store - The TaskStore instance
|
||||
* @param taskId - The task ID
|
||||
* @param subPath - Optional relative path within the task directory
|
||||
* @returns File listing response with entries sorted (dirs first, then files alphabetically)
|
||||
* @throws FileServiceError on validation or filesystem errors
|
||||
*/
|
||||
export async function listFiles(
|
||||
store: TaskStore,
|
||||
taskId: string,
|
||||
subPath?: string,
|
||||
): Promise<FileListResponse> {
|
||||
const taskBase = await getTaskBasePath(store, taskId);
|
||||
const targetPath = subPath ? validatePath(taskBase, subPath) : taskBase;
|
||||
async function listFilesForBasePath(basePath: string, subPath?: string): Promise<FileListResponse> {
|
||||
const targetPath = subPath ? validatePath(basePath, subPath) : basePath;
|
||||
|
||||
let stats;
|
||||
try {
|
||||
@@ -209,8 +211,7 @@ export async function listFiles(
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
// Calculate relative path from task base
|
||||
const relativeBase = relative(taskBase, targetPath);
|
||||
const relativeBase = relative(basePath, targetPath);
|
||||
|
||||
return {
|
||||
path: relativeBase || ".",
|
||||
@@ -227,26 +228,12 @@ export async function listFiles(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read file contents from a task directory.
|
||||
*
|
||||
* @param store - The TaskStore instance
|
||||
* @param taskId - The task ID
|
||||
* @param filePath - The relative file path
|
||||
* @returns File content response with content, mtime, and size
|
||||
* @throws FileServiceError on validation or filesystem errors
|
||||
*/
|
||||
export async function readFile(
|
||||
store: TaskStore,
|
||||
taskId: string,
|
||||
filePath: string,
|
||||
): Promise<FileContentResponse> {
|
||||
async function readFileForBasePath(basePath: string, filePath: string): Promise<FileContentResponse> {
|
||||
if (!filePath) {
|
||||
throw new FileServiceError("File path is required", "EINVAL");
|
||||
}
|
||||
|
||||
const taskBase = await getTaskBasePath(store, taskId);
|
||||
const resolvedPath = validatePath(taskBase, filePath);
|
||||
const resolvedPath = validatePath(basePath, filePath);
|
||||
|
||||
let stats;
|
||||
try {
|
||||
@@ -285,6 +272,98 @@ export async function readFile(
|
||||
}
|
||||
}
|
||||
|
||||
async function writeFileForBasePath(basePath: string, filePath: string, content: string): Promise<SaveFileResponse> {
|
||||
if (!filePath) {
|
||||
throw new FileServiceError("File path is required", "EINVAL");
|
||||
}
|
||||
|
||||
const contentBytes = Buffer.byteLength(content, "utf-8");
|
||||
if (contentBytes > MAX_FILE_SIZE) {
|
||||
throw new FileServiceError(`Content too large: ${contentBytes} bytes (max ${MAX_FILE_SIZE})`, "ETOOLARGE");
|
||||
}
|
||||
|
||||
const resolvedPath = validatePath(basePath, filePath);
|
||||
|
||||
try {
|
||||
const stats = await stat(resolvedPath);
|
||||
if (stats.isDirectory()) {
|
||||
throw new FileServiceError(`Cannot write to directory: ${filePath}`, "EISDIR");
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const parentDir = dirname(resolvedPath);
|
||||
try {
|
||||
const parentStats = await stat(parentDir);
|
||||
if (!parentStats.isDirectory()) {
|
||||
throw new FileServiceError(`Parent is not a directory: ${filePath}`, "ENOENT");
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
throw new FileServiceError(`Parent directory does not exist: ${filePath}`, "ENOENT");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
await fsWriteFile(resolvedPath, content, "utf-8");
|
||||
|
||||
const stats = await stat(resolvedPath);
|
||||
return {
|
||||
success: true,
|
||||
mtime: stats.mtime.toISOString(),
|
||||
size: stats.size,
|
||||
};
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
throw new FileServiceError(`Parent directory does not exist: ${filePath}`, "ENOENT");
|
||||
}
|
||||
if (err.code === "EACCES" || err.code === "EPERM") {
|
||||
throw new FileServiceError(`Permission denied: ${filePath}`, "EACCES");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List files in a task directory or subdirectory.
|
||||
*
|
||||
* @param store - The TaskStore instance
|
||||
* @param taskId - The task ID
|
||||
* @param subPath - Optional relative path within the task directory
|
||||
* @returns File listing response with entries sorted (dirs first, then files alphabetically)
|
||||
* @throws FileServiceError on validation or filesystem errors
|
||||
*/
|
||||
export async function listFiles(
|
||||
store: TaskStore,
|
||||
taskId: string,
|
||||
subPath?: string,
|
||||
): Promise<FileListResponse> {
|
||||
const taskBase = await getTaskBasePath(store, taskId);
|
||||
return listFilesForBasePath(taskBase, subPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read file contents from a task directory.
|
||||
*
|
||||
* @param store - The TaskStore instance
|
||||
* @param taskId - The task ID
|
||||
* @param filePath - The relative file path
|
||||
* @returns File content response with content, mtime, and size
|
||||
* @throws FileServiceError on validation or filesystem errors
|
||||
*/
|
||||
export async function readFile(
|
||||
store: TaskStore,
|
||||
taskId: string,
|
||||
filePath: string,
|
||||
): Promise<FileContentResponse> {
|
||||
const taskBase = await getTaskBasePath(store, taskId);
|
||||
return readFileForBasePath(taskBase, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write file contents to a task directory.
|
||||
*
|
||||
@@ -301,64 +380,8 @@ export async function writeFile(
|
||||
filePath: string,
|
||||
content: string,
|
||||
): Promise<SaveFileResponse> {
|
||||
if (!filePath) {
|
||||
throw new FileServiceError("File path is required", "EINVAL");
|
||||
}
|
||||
|
||||
// Check content size
|
||||
const contentBytes = Buffer.byteLength(content, "utf-8");
|
||||
if (contentBytes > MAX_FILE_SIZE) {
|
||||
throw new FileServiceError(`Content too large: ${contentBytes} bytes (max ${MAX_FILE_SIZE})`, "ETOOLARGE");
|
||||
}
|
||||
|
||||
const taskBase = await getTaskBasePath(store, taskId);
|
||||
const resolvedPath = validatePath(taskBase, filePath);
|
||||
|
||||
// Check if target is a directory
|
||||
try {
|
||||
const stats = await stat(resolvedPath);
|
||||
if (stats.isDirectory()) {
|
||||
throw new FileServiceError(`Cannot write to directory: ${filePath}`, "EISDIR");
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
// File doesn't exist, that's fine for writing
|
||||
}
|
||||
|
||||
// Check if parent directory exists
|
||||
const parentDir = dirname(resolvedPath);
|
||||
try {
|
||||
const parentStats = await stat(parentDir);
|
||||
if (!parentStats.isDirectory()) {
|
||||
throw new FileServiceError(`Parent is not a directory: ${filePath}`, "ENOENT");
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
throw new FileServiceError(`Parent directory does not exist: ${filePath}`, "ENOENT");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
await fsWriteFile(resolvedPath, content, "utf-8");
|
||||
|
||||
const stats = await stat(resolvedPath);
|
||||
return {
|
||||
success: true,
|
||||
mtime: stats.mtime.toISOString(),
|
||||
size: stats.size,
|
||||
};
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
throw new FileServiceError(`Parent directory does not exist: ${filePath}`, "ENOENT");
|
||||
}
|
||||
if (err.code === "EACCES" || err.code === "EPERM") {
|
||||
throw new FileServiceError(`Permission denied: ${filePath}`, "EACCES");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return writeFileForBasePath(taskBase, filePath, content);
|
||||
}
|
||||
|
||||
// ── Project File Functions ────────────────────────────────────────
|
||||
@@ -376,67 +399,7 @@ export async function listProjectFiles(
|
||||
subPath?: string,
|
||||
): Promise<FileListResponse> {
|
||||
const projectBase = getProjectBasePath(store);
|
||||
const targetPath = subPath ? validatePath(projectBase, subPath) : projectBase;
|
||||
|
||||
let stats;
|
||||
try {
|
||||
stats = await stat(targetPath);
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
throw new FileServiceError(`Directory not found: ${subPath || "."}`, "ENOENT");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
throw new FileServiceError(`Not a directory: ${subPath || "."}`, "ENOTDIR");
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = await readdir(targetPath, { withFileTypes: true });
|
||||
const fileNodes: FileNode[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
// Skip hidden files and directories
|
||||
if (entry.name.startsWith(".")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entryPath = join(targetPath, entry.name);
|
||||
const entryStats = await stat(entryPath);
|
||||
|
||||
fileNodes.push({
|
||||
name: entry.name,
|
||||
type: entry.isDirectory() ? "directory" : "file",
|
||||
size: entry.isFile() ? entryStats.size : undefined,
|
||||
mtime: entryStats.mtime.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Sort: directories first, then files, both alphabetically
|
||||
fileNodes.sort((a, b) => {
|
||||
if (a.type !== b.type) {
|
||||
return a.type === "directory" ? -1 : 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
// Calculate relative path from project base
|
||||
const relativeBase = relative(projectBase, targetPath);
|
||||
|
||||
return {
|
||||
path: relativeBase || ".",
|
||||
entries: fileNodes,
|
||||
};
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
throw new FileServiceError(`Directory not found: ${subPath || "."}`, "ENOENT");
|
||||
}
|
||||
if (err.code === "EACCES" || err.code === "EPERM") {
|
||||
throw new FileServiceError(`Permission denied: ${subPath || "."}`, "EACCES");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return listFilesForBasePath(projectBase, subPath);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -451,48 +414,8 @@ export async function readProjectFile(
|
||||
store: TaskStore,
|
||||
filePath: string,
|
||||
): Promise<FileContentResponse> {
|
||||
if (!filePath) {
|
||||
throw new FileServiceError("File path is required", "EINVAL");
|
||||
}
|
||||
|
||||
const projectBase = getProjectBasePath(store);
|
||||
const resolvedPath = validatePath(projectBase, filePath);
|
||||
|
||||
let stats;
|
||||
try {
|
||||
stats = await stat(resolvedPath);
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
throw new FileServiceError(`File not found: ${filePath}`, "ENOENT");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!stats.isFile()) {
|
||||
throw new FileServiceError(`Not a file: ${filePath}`, "EISDIR");
|
||||
}
|
||||
|
||||
if (stats.size > MAX_FILE_SIZE) {
|
||||
throw new FileServiceError(`File too large: ${stats.size} bytes (max ${MAX_FILE_SIZE})`, "ETOOLARGE");
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await fsReadFile(resolvedPath, "utf-8");
|
||||
|
||||
return {
|
||||
content,
|
||||
mtime: stats.mtime.toISOString(),
|
||||
size: stats.size,
|
||||
};
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
throw new FileServiceError(`File not found: ${filePath}`, "ENOENT");
|
||||
}
|
||||
if (err.code === "EACCES" || err.code === "EPERM") {
|
||||
throw new FileServiceError(`Permission denied: ${filePath}`, "EACCES");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return readFileForBasePath(projectBase, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -509,62 +432,43 @@ export async function writeProjectFile(
|
||||
filePath: string,
|
||||
content: string,
|
||||
): Promise<SaveFileResponse> {
|
||||
if (!filePath) {
|
||||
throw new FileServiceError("File path is required", "EINVAL");
|
||||
}
|
||||
|
||||
// Check content size
|
||||
const contentBytes = Buffer.byteLength(content, "utf-8");
|
||||
if (contentBytes > MAX_FILE_SIZE) {
|
||||
throw new FileServiceError(`Content too large: ${contentBytes} bytes (max ${MAX_FILE_SIZE})`, "ETOOLARGE");
|
||||
}
|
||||
|
||||
const projectBase = getProjectBasePath(store);
|
||||
const resolvedPath = validatePath(projectBase, filePath);
|
||||
|
||||
// Check if target is a directory
|
||||
try {
|
||||
const stats = await stat(resolvedPath);
|
||||
if (stats.isDirectory()) {
|
||||
throw new FileServiceError(`Cannot write to directory: ${filePath}`, "EISDIR");
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
// File doesn't exist, that's fine for writing
|
||||
}
|
||||
|
||||
// Check if parent directory exists
|
||||
const parentDir = dirname(resolvedPath);
|
||||
try {
|
||||
const parentStats = await stat(parentDir);
|
||||
if (!parentStats.isDirectory()) {
|
||||
throw new FileServiceError(`Parent is not a directory: ${filePath}`, "ENOENT");
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
throw new FileServiceError(`Parent directory does not exist: ${filePath}`, "ENOENT");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
await fsWriteFile(resolvedPath, content, "utf-8");
|
||||
|
||||
const stats = await stat(resolvedPath);
|
||||
return {
|
||||
success: true,
|
||||
mtime: stats.mtime.toISOString(),
|
||||
size: stats.size,
|
||||
};
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
throw new FileServiceError(`Parent directory does not exist: ${filePath}`, "ENOENT");
|
||||
}
|
||||
if (err.code === "EACCES" || err.code === "EPERM") {
|
||||
throw new FileServiceError(`Permission denied: ${filePath}`, "EACCES");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return writeFileForBasePath(projectBase, filePath, content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace-aware file listing used by the top-level dashboard file browser.
|
||||
*/
|
||||
export async function listWorkspaceFiles(
|
||||
store: TaskStore,
|
||||
workspace: WorkspaceId,
|
||||
subPath?: string,
|
||||
): Promise<FileListResponse> {
|
||||
const workspaceBase = await getWorkspaceBasePath(store, workspace);
|
||||
return listFilesForBasePath(workspaceBase, subPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace-aware file reading used by the top-level dashboard file browser.
|
||||
*/
|
||||
export async function readWorkspaceFile(
|
||||
store: TaskStore,
|
||||
workspace: WorkspaceId,
|
||||
filePath: string,
|
||||
): Promise<FileContentResponse> {
|
||||
const workspaceBase = await getWorkspaceBasePath(store, workspace);
|
||||
return readFileForBasePath(workspaceBase, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace-aware file writing used by the top-level dashboard file browser.
|
||||
*/
|
||||
export async function writeWorkspaceFile(
|
||||
store: TaskStore,
|
||||
workspace: WorkspaceId,
|
||||
filePath: string,
|
||||
content: string,
|
||||
): Promise<SaveFileResponse> {
|
||||
const workspaceBase = await getWorkspaceBasePath(store, workspace);
|
||||
return writeFileForBasePath(workspaceBase, filePath, content);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Router, type Request, type Response, type NextFunction } from "express";
|
||||
import multer from "multer";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { createReadStream, existsSync } from "node:fs";
|
||||
import { execSync } from "node:child_process";
|
||||
import type { TaskStore, Column, MergeResult, ScheduleType, ActivityEventType } from "@kb/core";
|
||||
import { COLUMNS, VALID_TRANSITIONS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, isGhAuthenticated, AUTOMATION_PRESETS, AutomationStore } from "@kb/core";
|
||||
@@ -9,7 +9,7 @@ import { GitHubClient, getCurrentGitHubRepo, parseBadgeUrl } from "./github.js";
|
||||
import { githubRateLimiter } from "./github-poll.js";
|
||||
import { terminalSessionManager } from "./terminal.js";
|
||||
import { getTerminalService } from "./terminal-service.js";
|
||||
import { listFiles, readFile, writeFile, listProjectFiles, readProjectFile, writeProjectFile, FileServiceError, type FileListResponse, type FileContentResponse, type SaveFileResponse } from "./file-service.js";
|
||||
import { listFiles, readFile, writeFile, listWorkspaceFiles, readWorkspaceFile, writeWorkspaceFile, FileServiceError, type FileListResponse, type FileContentResponse, type SaveFileResponse } from "./file-service.js";
|
||||
import { fetchAllProviderUsage } from "./usage.js";
|
||||
import {
|
||||
getGitHubAppConfig,
|
||||
@@ -565,6 +565,7 @@ function pushGitBranch(): GitPushResult {
|
||||
|
||||
export function createApiRoutes(store: TaskStore, options?: ServerOptions): Router {
|
||||
const router = Router();
|
||||
const sessionFilesCache = new Map<string, { files: string[]; expiresAt: number }>();
|
||||
|
||||
// Get GitHub token from options or env
|
||||
const githubToken = options?.githubToken ?? process.env.GITHUB_TOKEN;
|
||||
@@ -915,6 +916,55 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/tasks/:id/session-files", async (req, res) => {
|
||||
try {
|
||||
const task = await store.getTask(req.params.id);
|
||||
if (!task.worktree || !existsSync(task.worktree)) {
|
||||
res.json([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = sessionFilesCache.get(task.id);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
res.json(cached.files);
|
||||
return;
|
||||
}
|
||||
|
||||
const baseBranch = task.baseBranch ?? "main";
|
||||
let files: string[] = [];
|
||||
|
||||
try {
|
||||
const output = execSync(`git diff --name-only ${baseBranch}...HEAD`, {
|
||||
cwd: task.worktree,
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
}).trim();
|
||||
|
||||
files = output ? output.split("\n").filter(Boolean) : [];
|
||||
} catch {
|
||||
const fallback = execSync("git diff --name-only HEAD", {
|
||||
cwd: task.worktree,
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
}).trim();
|
||||
files = fallback ? fallback.split("\n").filter(Boolean) : [];
|
||||
}
|
||||
|
||||
sessionFilesCache.set(task.id, {
|
||||
files,
|
||||
expiresAt: Date.now() + 10000,
|
||||
});
|
||||
|
||||
res.json(files);
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
res.status(404).json({ error: `Task ${req.params.id} not found` });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message || "Internal server error" });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Get single task with prompt content
|
||||
router.get("/tasks/:id", async (req, res) => {
|
||||
try {
|
||||
@@ -2751,22 +2801,47 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
// ── Project File API Routes ───────────────────────────────────────
|
||||
// ── Workspace File API Routes ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/workspaces
|
||||
* List available file browser workspaces.
|
||||
* Returns: { project: string; tasks: Array<{ id: string; title?: string; worktree: string }> }
|
||||
*/
|
||||
router.get("/workspaces", async (_req, res) => {
|
||||
try {
|
||||
const tasks = await store.listTasks();
|
||||
res.json({
|
||||
project: store.getRootDir(),
|
||||
tasks: tasks
|
||||
.filter((task) => typeof task.worktree === "string" && task.worktree.length > 0 && existsSync(task.worktree))
|
||||
.map((task) => ({
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
worktree: task.worktree!,
|
||||
})),
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || "Internal server error" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/files
|
||||
* List files in project root directory.
|
||||
* Query param: ?path=relative/path for subdirectory navigation.
|
||||
* List files in the requested workspace. Defaults to the project root when omitted.
|
||||
* Query params: ?workspace=project|TASK-ID and ?path=relative/path for subdirectory navigation.
|
||||
* Returns: { path: string; entries: FileNode[] }
|
||||
*/
|
||||
router.get("/files", async (req, res) => {
|
||||
try {
|
||||
const { path: subPath } = req.query;
|
||||
const result = await listProjectFiles(store, typeof subPath === "string" ? subPath : undefined);
|
||||
const { path: subPath, workspace } = req.query;
|
||||
const workspaceId = typeof workspace === "string" && workspace.length > 0 ? workspace : "project";
|
||||
const result = await listWorkspaceFiles(store, workspaceId, typeof subPath === "string" ? subPath : undefined);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
if (err instanceof FileServiceError) {
|
||||
const status = err.code === "ENOENT" ? 404
|
||||
const status = err.code === "ENOTASK" ? 404
|
||||
: err.code === "ENOENT" ? 404
|
||||
: err.code === "EACCES" ? 403
|
||||
: 400;
|
||||
res.status(status).json({ error: err.message, code: err.code });
|
||||
@@ -2778,17 +2853,22 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
|
||||
/**
|
||||
* GET /api/files/{*filepath}
|
||||
* Read file contents from project directory.
|
||||
* Read file contents from the requested workspace. Defaults to the project root when omitted.
|
||||
* Query param: ?workspace=project|TASK-ID
|
||||
* Returns: { content: string; mtime: string; size: number }
|
||||
*/
|
||||
router.get("/files/{*filepath}", async (req, res) => {
|
||||
try {
|
||||
const filePath = Array.isArray(req.params.filepath) ? req.params.filepath[0] : req.params.filepath ?? "";
|
||||
const result = await readProjectFile(store, filePath);
|
||||
const workspace = typeof req.query.workspace === "string" && req.query.workspace.length > 0
|
||||
? req.query.workspace
|
||||
: "project";
|
||||
const result = await readWorkspaceFile(store, workspace, filePath);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
if (err instanceof FileServiceError) {
|
||||
const status = err.code === "ENOENT" ? 404
|
||||
const status = err.code === "ENOTASK" ? 404
|
||||
: err.code === "ENOENT" ? 404
|
||||
: err.code === "EACCES" ? 403
|
||||
: err.code === "ETOOLARGE" ? 413
|
||||
: err.code === "EINVAL" && err.message.includes("Binary file") ? 415
|
||||
@@ -2802,7 +2882,8 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
|
||||
/**
|
||||
* POST /api/files/{*filepath}
|
||||
* Write file contents to project directory.
|
||||
* Write file contents to the requested workspace. Defaults to the project root when omitted.
|
||||
* Query param: ?workspace=project|TASK-ID
|
||||
* Body: { content: string }
|
||||
* Returns: { success: true; mtime: string; size: number }
|
||||
*/
|
||||
@@ -2810,17 +2891,21 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
try {
|
||||
const filePath = Array.isArray(req.params.filepath) ? req.params.filepath[0] : req.params.filepath ?? "";
|
||||
const { content } = req.body;
|
||||
const workspace = typeof req.query.workspace === "string" && req.query.workspace.length > 0
|
||||
? req.query.workspace
|
||||
: "project";
|
||||
|
||||
if (typeof content !== "string") {
|
||||
res.status(400).json({ error: "content is required and must be a string" });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await writeProjectFile(store, filePath, content);
|
||||
const result = await writeWorkspaceFile(store, workspace, filePath, content);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
if (err instanceof FileServiceError) {
|
||||
const status = err.code === "ENOENT" ? 404
|
||||
const status = err.code === "ENOTASK" ? 404
|
||||
: err.code === "ENOENT" ? 404
|
||||
: err.code === "EACCES" ? 403
|
||||
: err.code === "ETOOLARGE" ? 413
|
||||
: 400;
|
||||
|
||||
Reference in New Issue
Block a user