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:
@@ -6,14 +6,14 @@ import type { FileListResponse } from "../../api";
|
||||
|
||||
// Mock the api module
|
||||
vi.mock("../../api", () => ({
|
||||
fetchProjectFileList: vi.fn(),
|
||||
fetchWorkspaceFileList: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchProjectFileList = vi.mocked(api.fetchProjectFileList);
|
||||
const mockFetchWorkspaceFileList = vi.mocked(api.fetchWorkspaceFileList);
|
||||
|
||||
describe("useProjectFileBrowser", () => {
|
||||
beforeEach(() => {
|
||||
mockFetchProjectFileList.mockReset();
|
||||
mockFetchWorkspaceFileList.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -37,20 +37,17 @@ describe("useProjectFileBrowser", () => {
|
||||
{ name: "package.json", type: "file", size: 100, mtime: "2024-01-01T00:00:00Z" },
|
||||
],
|
||||
};
|
||||
mockFetchProjectFileList.mockResolvedValueOnce(mockResponse);
|
||||
mockFetchWorkspaceFileList.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useProjectFileBrowser("/project", true));
|
||||
|
||||
// Should start loading
|
||||
expect(result.current.loading).toBe(true);
|
||||
|
||||
// Wait for fetch to complete
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.entries).toHaveLength(2);
|
||||
expect(result.current.entries[0].name).toBe("src");
|
||||
expect(result.current.entries[1].name).toBe("package.json");
|
||||
expect(mockFetchProjectFileList).toHaveBeenCalledWith(undefined);
|
||||
expect(mockFetchWorkspaceFileList).toHaveBeenCalledWith("project", undefined);
|
||||
});
|
||||
|
||||
it("fetches subdirectory when path changes", async () => {
|
||||
@@ -63,7 +60,7 @@ describe("useProjectFileBrowser", () => {
|
||||
entries: [{ name: "index.ts", type: "file", size: 200, mtime: "2024-01-01T00:00:00Z" }],
|
||||
};
|
||||
|
||||
mockFetchProjectFileList
|
||||
mockFetchWorkspaceFileList
|
||||
.mockResolvedValueOnce(rootResponse)
|
||||
.mockResolvedValueOnce(subdirResponse);
|
||||
|
||||
@@ -72,7 +69,6 @@ describe("useProjectFileBrowser", () => {
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.entries).toHaveLength(1);
|
||||
|
||||
// Navigate to subdirectory
|
||||
act(() => {
|
||||
result.current.setPath("src");
|
||||
});
|
||||
@@ -80,11 +76,11 @@ describe("useProjectFileBrowser", () => {
|
||||
await waitFor(() => expect(result.current.currentPath).toBe("src"));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(mockFetchProjectFileList).toHaveBeenLastCalledWith("src");
|
||||
expect(mockFetchWorkspaceFileList).toHaveBeenLastCalledWith("project", "src");
|
||||
});
|
||||
|
||||
it("handles fetch errors", async () => {
|
||||
mockFetchProjectFileList.mockRejectedValueOnce(new Error("Failed to load files"));
|
||||
mockFetchWorkspaceFileList.mockRejectedValueOnce(new Error("Failed to load files"));
|
||||
|
||||
const { result } = renderHook(() => useProjectFileBrowser("/project", true));
|
||||
|
||||
@@ -107,7 +103,7 @@ describe("useProjectFileBrowser", () => {
|
||||
],
|
||||
};
|
||||
|
||||
mockFetchProjectFileList
|
||||
mockFetchWorkspaceFileList
|
||||
.mockResolvedValueOnce(initialResponse)
|
||||
.mockResolvedValueOnce(refreshedResponse);
|
||||
|
||||
@@ -116,17 +112,16 @@ describe("useProjectFileBrowser", () => {
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.entries).toHaveLength(1);
|
||||
|
||||
// Refresh
|
||||
act(() => {
|
||||
result.current.refresh();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.entries).toHaveLength(2));
|
||||
expect(mockFetchProjectFileList).toHaveBeenCalledTimes(2);
|
||||
expect(mockFetchWorkspaceFileList).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("clears error when path changes", async () => {
|
||||
mockFetchProjectFileList
|
||||
mockFetchWorkspaceFileList
|
||||
.mockRejectedValueOnce(new Error("Failed to load files"))
|
||||
.mockResolvedValueOnce({
|
||||
path: ".",
|
||||
@@ -137,7 +132,6 @@ describe("useProjectFileBrowser", () => {
|
||||
|
||||
await waitFor(() => expect(result.current.error).toBe("Failed to load files"));
|
||||
|
||||
// Change path should clear error
|
||||
act(() => {
|
||||
result.current.setPath("subdir");
|
||||
});
|
||||
@@ -148,10 +142,9 @@ describe("useProjectFileBrowser", () => {
|
||||
it("does not fetch when disabled", async () => {
|
||||
renderHook(() => useProjectFileBrowser("/project", false));
|
||||
|
||||
// Wait a bit to ensure no fetch happens
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(mockFetchProjectFileList).not.toHaveBeenCalled();
|
||||
expect(mockFetchWorkspaceFileList).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels in-flight requests on unmount", async () => {
|
||||
@@ -159,23 +152,19 @@ describe("useProjectFileBrowser", () => {
|
||||
const fetchPromise = new Promise<FileListResponse>((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
});
|
||||
mockFetchProjectFileList.mockReturnValueOnce(fetchPromise);
|
||||
mockFetchWorkspaceFileList.mockReturnValueOnce(fetchPromise);
|
||||
|
||||
const { unmount } = renderHook(() => useProjectFileBrowser("/project", true));
|
||||
|
||||
// Unmount before fetch completes
|
||||
unmount();
|
||||
|
||||
// Complete the fetch after unmount
|
||||
resolveFetch!({
|
||||
path: ".",
|
||||
entries: [{ name: "file.txt", type: "file", size: 100, mtime: "2024-01-01T00:00:00Z" }],
|
||||
});
|
||||
|
||||
// Wait a bit to ensure state update doesn't happen
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// Should not throw or have any issues
|
||||
expect(mockFetchProjectFileList).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetchWorkspaceFileList).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,319 +6,221 @@ import type { FileContentResponse, SaveFileResponse } from "../../api";
|
||||
|
||||
// Mock the api module
|
||||
vi.mock("../../api", () => ({
|
||||
fetchProjectFileContent: vi.fn(),
|
||||
saveProjectFileContent: vi.fn(),
|
||||
fetchWorkspaceFileContent: vi.fn(),
|
||||
saveWorkspaceFileContent: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchProjectFileContent = vi.mocked(api.fetchProjectFileContent);
|
||||
const mockSaveProjectFileContent = vi.mocked(api.saveProjectFileContent);
|
||||
const mockFetchWorkspaceFileContent = vi.mocked(api.fetchWorkspaceFileContent);
|
||||
const mockSaveWorkspaceFileContent = vi.mocked(api.saveWorkspaceFileContent);
|
||||
|
||||
describe("useProjectFileEditor", () => {
|
||||
beforeEach(() => {
|
||||
mockFetchProjectFileContent.mockReset();
|
||||
mockSaveProjectFileContent.mockReset();
|
||||
mockFetchWorkspaceFileContent.mockReset();
|
||||
mockSaveWorkspaceFileContent.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("initializes with empty content when no file selected", () => {
|
||||
it("initializes with empty content when no file is selected", () => {
|
||||
const { result } = renderHook(() => useProjectFileEditor("/project", null, true));
|
||||
|
||||
expect(result.current.content).toBe("");
|
||||
expect(result.current.originalContent).toBe("");
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.saving).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.hasChanges).toBe(false);
|
||||
expect(result.current.mtime).toBeNull();
|
||||
});
|
||||
|
||||
it("initializes with empty content when disabled", () => {
|
||||
const { result } = renderHook(() => useProjectFileEditor("/project", "src/index.ts", false));
|
||||
|
||||
expect(result.current.content).toBe("");
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(mockFetchProjectFileContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches file content when filePath is provided and enabled", async () => {
|
||||
it("loads file content when file is selected", async () => {
|
||||
const mockResponse: FileContentResponse = {
|
||||
content: "console.log('hello');",
|
||||
mtime: "2024-01-01T00:00:00Z",
|
||||
size: 21,
|
||||
};
|
||||
mockFetchProjectFileContent.mockResolvedValueOnce(mockResponse);
|
||||
mockFetchWorkspaceFileContent.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useProjectFileEditor("/project", "src/index.ts", true));
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.content).toBe("console.log('hello');");
|
||||
expect(result.current.originalContent).toBe("console.log('hello');");
|
||||
expect(result.current.mtime).toBe("2024-01-01T00:00:00Z");
|
||||
expect(result.current.hasChanges).toBe(false);
|
||||
expect(mockFetchProjectFileContent).toHaveBeenCalledWith("src/index.ts");
|
||||
expect(mockFetchWorkspaceFileContent).toHaveBeenCalledWith("project", "src/index.ts");
|
||||
});
|
||||
|
||||
it("tracks content changes", async () => {
|
||||
const mockResponse: FileContentResponse = {
|
||||
content: "console.log('hello');",
|
||||
content: "original content",
|
||||
mtime: "2024-01-01T00:00:00Z",
|
||||
size: 21,
|
||||
size: 16,
|
||||
};
|
||||
mockFetchProjectFileContent.mockResolvedValueOnce(mockResponse);
|
||||
mockFetchWorkspaceFileContent.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useProjectFileEditor("/project", "src/index.ts", true));
|
||||
const { result } = renderHook(() => useProjectFileEditor("/project", "file.txt", true));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.hasChanges).toBe(false);
|
||||
|
||||
// Edit content
|
||||
act(() => {
|
||||
result.current.setContent("console.log('world');");
|
||||
result.current.setContent("modified content");
|
||||
});
|
||||
|
||||
expect(result.current.content).toBe("console.log('world');");
|
||||
expect(result.current.content).toBe("modified content");
|
||||
expect(result.current.hasChanges).toBe(true);
|
||||
});
|
||||
|
||||
it("saves file content", async () => {
|
||||
it("saves changes successfully", async () => {
|
||||
const loadResponse: FileContentResponse = {
|
||||
content: "console.log('hello');",
|
||||
content: "original",
|
||||
mtime: "2024-01-01T00:00:00Z",
|
||||
size: 21,
|
||||
size: 8,
|
||||
};
|
||||
const saveResponse: SaveFileResponse = {
|
||||
success: true,
|
||||
mtime: "2024-01-02T00:00:00Z",
|
||||
size: 22,
|
||||
size: 16,
|
||||
};
|
||||
|
||||
mockFetchProjectFileContent.mockResolvedValueOnce(loadResponse);
|
||||
mockSaveProjectFileContent.mockResolvedValueOnce(saveResponse);
|
||||
mockFetchWorkspaceFileContent.mockResolvedValueOnce(loadResponse);
|
||||
mockSaveWorkspaceFileContent.mockResolvedValueOnce(saveResponse);
|
||||
|
||||
const { result } = renderHook(() => useProjectFileEditor("/project", "src/index.ts", true));
|
||||
const { result } = renderHook(() => useProjectFileEditor("/project", "file.txt", true));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
// Edit and save
|
||||
act(() => {
|
||||
result.current.setContent("console.log('world');");
|
||||
result.current.setContent("modified content");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
|
||||
expect(mockSaveProjectFileContent).toHaveBeenCalledWith("src/index.ts", "console.log('world');");
|
||||
expect(result.current.originalContent).toBe("console.log('world');");
|
||||
expect(result.current.hasChanges).toBe(false);
|
||||
expect(result.current.mtime).toBe("2024-01-02T00:00:00Z");
|
||||
});
|
||||
|
||||
it("does not save when there are no changes", async () => {
|
||||
const mockResponse: FileContentResponse = {
|
||||
content: "console.log('hello');",
|
||||
mtime: "2024-01-01T00:00:00Z",
|
||||
size: 21,
|
||||
};
|
||||
mockFetchProjectFileContent.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useProjectFileEditor("/project", "src/index.ts", true));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
// Try to save without changes
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
|
||||
expect(mockSaveProjectFileContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles fetch errors", async () => {
|
||||
mockFetchProjectFileContent.mockRejectedValueOnce(new Error("File not found"));
|
||||
|
||||
const { result } = renderHook(() => useProjectFileEditor("/project", "src/missing.ts", true));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.error).toBe("File not found");
|
||||
expect(result.current.content).toBe("");
|
||||
});
|
||||
|
||||
it("handles save errors", async () => {
|
||||
const loadResponse: FileContentResponse = {
|
||||
content: "console.log('hello');",
|
||||
mtime: "2024-01-01T00:00:00Z",
|
||||
size: 21,
|
||||
};
|
||||
mockFetchProjectFileContent.mockResolvedValueOnce(loadResponse);
|
||||
mockSaveProjectFileContent.mockRejectedValueOnce(new Error("Permission denied"));
|
||||
|
||||
const { result } = renderHook(() => useProjectFileEditor("/project", "src/index.ts", true));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
// Edit and try to save
|
||||
act(() => {
|
||||
result.current.setContent("console.log('world');");
|
||||
});
|
||||
|
||||
let saveError: Error | undefined;
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.save();
|
||||
} catch (err) {
|
||||
saveError = err as Error;
|
||||
}
|
||||
});
|
||||
|
||||
expect(saveError?.message).toBe("Permission denied");
|
||||
expect(result.current.error).toBe("Permission denied");
|
||||
// Original content should not be updated on error
|
||||
expect(result.current.originalContent).toBe("console.log('hello');");
|
||||
expect(result.current.hasChanges).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
|
||||
expect(result.current.originalContent).toBe("modified content");
|
||||
expect(result.current.mtime).toBe("2024-01-02T00:00:00Z");
|
||||
expect(result.current.hasChanges).toBe(false);
|
||||
expect(mockSaveWorkspaceFileContent).toHaveBeenCalledWith("project", "file.txt", "modified content");
|
||||
});
|
||||
|
||||
it("clears content when filePath changes", async () => {
|
||||
const firstResponse: FileContentResponse = {
|
||||
content: "// file 1",
|
||||
mtime: "2024-01-01T00:00:00Z",
|
||||
size: 10,
|
||||
};
|
||||
const secondResponse: FileContentResponse = {
|
||||
content: "// file 2",
|
||||
mtime: "2024-01-02T00:00:00Z",
|
||||
size: 10,
|
||||
};
|
||||
it("handles load errors", async () => {
|
||||
mockFetchWorkspaceFileContent.mockRejectedValueOnce(new Error("Failed to load file"));
|
||||
|
||||
mockFetchProjectFileContent
|
||||
.mockResolvedValueOnce(firstResponse)
|
||||
.mockResolvedValueOnce(secondResponse);
|
||||
const { result } = renderHook(() => useProjectFileEditor("/project", "missing.txt", true));
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ filePath }) => useProjectFileEditor("/project", filePath, true),
|
||||
{ initialProps: { filePath: "file1.ts" } }
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.content).toBe("// file 1"));
|
||||
|
||||
// Change file
|
||||
rerender({ filePath: "file2.ts" });
|
||||
|
||||
await waitFor(() => expect(result.current.content).toBe("// file 2"));
|
||||
});
|
||||
|
||||
it("clears content when disabled", async () => {
|
||||
const mockResponse: FileContentResponse = {
|
||||
content: "console.log('hello');",
|
||||
mtime: "2024-01-01T00:00:00Z",
|
||||
size: 21,
|
||||
};
|
||||
mockFetchProjectFileContent.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ enabled }) => useProjectFileEditor("/project", "src/index.ts", enabled),
|
||||
{ initialProps: { enabled: true } }
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.content).toBe("console.log('hello');"));
|
||||
|
||||
// Disable
|
||||
rerender({ enabled: false });
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.error).toBe("Failed to load file");
|
||||
expect(result.current.content).toBe("");
|
||||
expect(result.current.originalContent).toBe("");
|
||||
expect(result.current.mtime).toBeNull();
|
||||
});
|
||||
|
||||
it("clears error when content is edited", async () => {
|
||||
mockFetchProjectFileContent.mockRejectedValueOnce(new Error("File not found"));
|
||||
it("handles save errors", async () => {
|
||||
const loadResponse: FileContentResponse = {
|
||||
content: "original",
|
||||
mtime: "2024-01-01T00:00:00Z",
|
||||
size: 8,
|
||||
};
|
||||
mockFetchWorkspaceFileContent.mockResolvedValueOnce(loadResponse);
|
||||
mockSaveWorkspaceFileContent.mockRejectedValueOnce(new Error("Failed to save file"));
|
||||
|
||||
const { result } = renderHook(() => useProjectFileEditor("/project", "src/missing.ts", true));
|
||||
const { result } = renderHook(() => useProjectFileEditor("/project", "file.txt", true));
|
||||
|
||||
await waitFor(() => expect(result.current.error).toBe("File not found"));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
// Edit content should clear error
|
||||
act(() => {
|
||||
result.current.setContent("new content");
|
||||
result.current.setContent("modified");
|
||||
});
|
||||
|
||||
await expect(result.current.save()).rejects.toThrow("Failed to save file");
|
||||
expect(result.current.error).toBe("Failed to save file");
|
||||
expect(result.current.hasChanges).toBe(true);
|
||||
});
|
||||
|
||||
it("does not load when disabled", async () => {
|
||||
renderHook(() => useProjectFileEditor("/project", "file.txt", false));
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(mockFetchWorkspaceFileContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not save when there are no changes", async () => {
|
||||
const loadResponse: FileContentResponse = {
|
||||
content: "original",
|
||||
mtime: "2024-01-01T00:00:00Z",
|
||||
size: 8,
|
||||
};
|
||||
mockFetchWorkspaceFileContent.mockResolvedValueOnce(loadResponse);
|
||||
|
||||
const { result } = renderHook(() => useProjectFileEditor("/project", "file.txt", true));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
|
||||
expect(mockSaveWorkspaceFileContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears error when content changes", async () => {
|
||||
const loadResponse: FileContentResponse = {
|
||||
content: "original",
|
||||
mtime: "2024-01-01T00:00:00Z",
|
||||
size: 8,
|
||||
};
|
||||
mockFetchWorkspaceFileContent.mockResolvedValueOnce(loadResponse);
|
||||
mockSaveWorkspaceFileContent.mockRejectedValueOnce(new Error("Save failed"));
|
||||
|
||||
const { result } = renderHook(() => useProjectFileEditor("/project", "file.txt", true));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
act(() => {
|
||||
result.current.setContent("modified");
|
||||
});
|
||||
|
||||
await expect(result.current.save()).rejects.toThrow("Save failed");
|
||||
expect(result.current.error).toBe("Save failed");
|
||||
|
||||
act(() => {
|
||||
result.current.setContent("modified again");
|
||||
});
|
||||
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("shows saving state during save operation", async () => {
|
||||
it("resets state when filePath becomes null", async () => {
|
||||
const loadResponse: FileContentResponse = {
|
||||
content: "console.log('hello');",
|
||||
content: "content",
|
||||
mtime: "2024-01-01T00:00:00Z",
|
||||
size: 21,
|
||||
};
|
||||
const saveResponse: SaveFileResponse = {
|
||||
success: true,
|
||||
mtime: "2024-01-02T00:00:00Z",
|
||||
size: 22,
|
||||
size: 7,
|
||||
};
|
||||
mockFetchWorkspaceFileContent.mockResolvedValueOnce(loadResponse);
|
||||
|
||||
let resolveSave: (value: SaveFileResponse) => void;
|
||||
const savePromise = new Promise<SaveFileResponse>((resolve) => {
|
||||
resolveSave = resolve;
|
||||
});
|
||||
|
||||
mockFetchProjectFileContent.mockResolvedValueOnce(loadResponse);
|
||||
mockSaveProjectFileContent.mockReturnValueOnce(savePromise);
|
||||
|
||||
const { result } = renderHook(() => useProjectFileEditor("/project", "src/index.ts", true));
|
||||
const { result, rerender } = renderHook(
|
||||
({ filePath }) => useProjectFileEditor("/project", filePath, true),
|
||||
{ initialProps: { filePath: "file.txt" } }
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.content).toBe("content");
|
||||
|
||||
// Edit and save
|
||||
act(() => {
|
||||
result.current.setContent("console.log('world');");
|
||||
});
|
||||
rerender({ filePath: null });
|
||||
|
||||
// Start save
|
||||
let savePromiseResolved = false;
|
||||
act(() => {
|
||||
result.current.save().then(() => {
|
||||
savePromiseResolved = true;
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.saving).toBe(true);
|
||||
|
||||
// Complete save
|
||||
resolveSave!(saveResponse);
|
||||
await waitFor(() => expect(savePromiseResolved).toBe(true));
|
||||
|
||||
expect(result.current.saving).toBe(false);
|
||||
});
|
||||
|
||||
it("cancels in-flight fetch on unmount", async () => {
|
||||
let resolveFetch: (value: FileContentResponse) => void;
|
||||
const fetchPromise = new Promise<FileContentResponse>((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
});
|
||||
mockFetchProjectFileContent.mockReturnValueOnce(fetchPromise);
|
||||
|
||||
const { unmount } = renderHook(() => useProjectFileEditor("/project", "src/index.ts", true));
|
||||
|
||||
// Unmount before fetch completes
|
||||
unmount();
|
||||
|
||||
// Complete the fetch after unmount
|
||||
resolveFetch!({
|
||||
content: "console.log('hello');",
|
||||
mtime: "2024-01-01T00:00:00Z",
|
||||
size: 21,
|
||||
});
|
||||
|
||||
// Wait a bit to ensure state update doesn't happen
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(mockFetchProjectFileContent).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.content).toBe("");
|
||||
expect(result.current.originalContent).toBe("");
|
||||
expect(result.current.mtime).toBeNull();
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { useSessionFiles } from "../useSessionFiles";
|
||||
import * as api from "../../api";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchSessionFiles: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchSessionFiles = vi.mocked(api.fetchSessionFiles);
|
||||
|
||||
describe("useSessionFiles", () => {
|
||||
beforeEach(() => {
|
||||
mockFetchSessionFiles.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("fetches session files for active tasks with a worktree", async () => {
|
||||
mockFetchSessionFiles.mockResolvedValueOnce(["src/a.ts", "src/b.ts"]);
|
||||
|
||||
const { result } = renderHook(() => useSessionFiles("KB-123", "/repo/.worktrees/kb-123", "in-progress"));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.files).toEqual(["src/a.ts", "src/b.ts"]);
|
||||
expect(mockFetchSessionFiles).toHaveBeenCalledWith("KB-123");
|
||||
});
|
||||
|
||||
it("does not fetch for tasks without worktrees or inactive columns", async () => {
|
||||
const { result: noWorktree } = renderHook(() => useSessionFiles("KB-123", undefined, "in-progress"));
|
||||
const { result: inactive } = renderHook(() => useSessionFiles("KB-123", "/repo/.worktrees/kb-123", "todo"));
|
||||
|
||||
await waitFor(() => expect(noWorktree.current.loading).toBe(false));
|
||||
await waitFor(() => expect(inactive.current.loading).toBe(false));
|
||||
|
||||
expect(noWorktree.current.files).toEqual([]);
|
||||
expect(inactive.current.files).toEqual([]);
|
||||
expect(mockFetchSessionFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns empty files on fetch failure", async () => {
|
||||
mockFetchSessionFiles.mockRejectedValueOnce(new Error("boom"));
|
||||
|
||||
const { result } = renderHook(() => useSessionFiles("KB-123", "/repo/.worktrees/kb-123", "in-review"));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.files).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { useWorkspaceFileBrowser } from "../useWorkspaceFileBrowser";
|
||||
import * as api from "../../api";
|
||||
import type { FileListResponse } from "../../api";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchWorkspaceFileList: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchWorkspaceFileList = vi.mocked(api.fetchWorkspaceFileList);
|
||||
|
||||
describe("useWorkspaceFileBrowser", () => {
|
||||
beforeEach(() => {
|
||||
mockFetchWorkspaceFileList.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("fetches workspace files when enabled", async () => {
|
||||
const mockResponse: FileListResponse = {
|
||||
path: ".",
|
||||
entries: [{ name: "src", type: "directory", mtime: "2024-01-01T00:00:00Z" }],
|
||||
};
|
||||
mockFetchWorkspaceFileList.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceFileBrowser("KB-123", true));
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.entries).toEqual(mockResponse.entries);
|
||||
expect(mockFetchWorkspaceFileList).toHaveBeenCalledWith("KB-123", undefined);
|
||||
});
|
||||
|
||||
it("resets path when workspace changes", async () => {
|
||||
mockFetchWorkspaceFileList
|
||||
.mockResolvedValueOnce({ path: ".", entries: [{ name: "src", type: "directory", mtime: "2024-01-01T00:00:00Z" }] })
|
||||
.mockResolvedValueOnce({ path: "src", entries: [{ name: "index.ts", type: "file", size: 1, mtime: "2024-01-01T00:00:00Z" }] })
|
||||
.mockResolvedValueOnce({ path: ".", entries: [{ name: "README.md", type: "file", size: 5, mtime: "2024-01-01T00:00:00Z" }] });
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ workspace }) => useWorkspaceFileBrowser(workspace, true),
|
||||
{ initialProps: { workspace: "KB-123" } },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
act(() => {
|
||||
result.current.setPath("src");
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.currentPath).toBe("src"));
|
||||
await waitFor(() => expect(mockFetchWorkspaceFileList).toHaveBeenLastCalledWith("KB-123", "src"));
|
||||
|
||||
rerender({ workspace: "project" });
|
||||
|
||||
await waitFor(() => expect(result.current.currentPath).toBe("."));
|
||||
await waitFor(() => expect(mockFetchWorkspaceFileList).toHaveBeenLastCalledWith("project", undefined));
|
||||
});
|
||||
|
||||
it("handles fetch errors", async () => {
|
||||
mockFetchWorkspaceFileList.mockRejectedValueOnce(new Error("Failed to load files"));
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceFileBrowser("KB-404", true));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.error).toBe("Failed to load files");
|
||||
expect(result.current.entries).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { useWorkspaceFileEditor } from "../useWorkspaceFileEditor";
|
||||
import * as api from "../../api";
|
||||
import type { FileContentResponse, SaveFileResponse } from "../../api";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchWorkspaceFileContent: vi.fn(),
|
||||
saveWorkspaceFileContent: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchWorkspaceFileContent = vi.mocked(api.fetchWorkspaceFileContent);
|
||||
const mockSaveWorkspaceFileContent = vi.mocked(api.saveWorkspaceFileContent);
|
||||
|
||||
describe("useWorkspaceFileEditor", () => {
|
||||
beforeEach(() => {
|
||||
mockFetchWorkspaceFileContent.mockReset();
|
||||
mockSaveWorkspaceFileContent.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("loads content for the selected workspace file", async () => {
|
||||
const response: FileContentResponse = {
|
||||
content: "hello",
|
||||
mtime: "2024-01-01T00:00:00Z",
|
||||
size: 5,
|
||||
};
|
||||
mockFetchWorkspaceFileContent.mockResolvedValueOnce(response);
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceFileEditor("KB-123", "src/index.ts", true));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.content).toBe("hello");
|
||||
expect(result.current.originalContent).toBe("hello");
|
||||
expect(mockFetchWorkspaceFileContent).toHaveBeenCalledWith("KB-123", "src/index.ts");
|
||||
});
|
||||
|
||||
it("saves workspace file changes", async () => {
|
||||
const loadResponse: FileContentResponse = {
|
||||
content: "original",
|
||||
mtime: "2024-01-01T00:00:00Z",
|
||||
size: 8,
|
||||
};
|
||||
const saveResponse: SaveFileResponse = {
|
||||
success: true,
|
||||
mtime: "2024-01-02T00:00:00Z",
|
||||
size: 9,
|
||||
};
|
||||
|
||||
mockFetchWorkspaceFileContent.mockResolvedValueOnce(loadResponse);
|
||||
mockSaveWorkspaceFileContent.mockResolvedValueOnce(saveResponse);
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceFileEditor("project", "README.md", true));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
act(() => {
|
||||
result.current.setContent("changed");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
|
||||
expect(mockSaveWorkspaceFileContent).toHaveBeenCalledWith("project", "README.md", "changed");
|
||||
expect(result.current.hasChanges).toBe(false);
|
||||
expect(result.current.mtime).toBe("2024-01-02T00:00:00Z");
|
||||
});
|
||||
|
||||
it("resets state when disabled or file is cleared", async () => {
|
||||
const response: FileContentResponse = {
|
||||
content: "hello",
|
||||
mtime: "2024-01-01T00:00:00Z",
|
||||
size: 5,
|
||||
};
|
||||
mockFetchWorkspaceFileContent.mockResolvedValueOnce(response);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ workspace, filePath, enabled }) => useWorkspaceFileEditor(workspace, filePath, enabled),
|
||||
{ initialProps: { workspace: "KB-123", filePath: "src/index.ts", enabled: true } },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.content).toBe("hello");
|
||||
|
||||
rerender({ workspace: "KB-123", filePath: null, enabled: true });
|
||||
|
||||
expect(result.current.content).toBe("");
|
||||
expect(result.current.originalContent).toBe("");
|
||||
expect(result.current.mtime).toBeNull();
|
||||
});
|
||||
});
|
||||
74
packages/dashboard/app/hooks/__tests__/useWorkspaces.test.ts
Normal file
74
packages/dashboard/app/hooks/__tests__/useWorkspaces.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { useWorkspaces } from "../useWorkspaces";
|
||||
import * as api from "../../api";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchWorkspaces: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchWorkspaces = vi.mocked(api.fetchWorkspaces);
|
||||
|
||||
describe("useWorkspaces", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
mockFetchWorkspaces.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("loads project and task workspaces", async () => {
|
||||
mockFetchWorkspaces.mockResolvedValueOnce({
|
||||
project: "/Users/test/repo",
|
||||
tasks: [{ id: "KB-123", title: "Feature", worktree: "/Users/test/.worktrees/kb-123" }],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useWorkspaces());
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.projectName).toBe("repo");
|
||||
expect(result.current.workspaces).toEqual([
|
||||
{
|
||||
id: "KB-123",
|
||||
label: "KB-123",
|
||||
title: "Feature",
|
||||
worktree: "/Users/test/.worktrees/kb-123",
|
||||
kind: "task",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("polls for workspace updates", async () => {
|
||||
mockFetchWorkspaces
|
||||
.mockResolvedValueOnce({ project: "/repo", tasks: [] })
|
||||
.mockResolvedValueOnce({
|
||||
project: "/repo",
|
||||
tasks: [{ id: "KB-200", title: "Later", worktree: "/repo/.worktrees/kb-200" }],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useWorkspaces());
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.workspaces).toEqual([]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10000);
|
||||
|
||||
await waitFor(() => expect(result.current.workspaces).toHaveLength(1));
|
||||
expect(mockFetchWorkspaces).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("surfaces fetch errors", async () => {
|
||||
mockFetchWorkspaces.mockRejectedValueOnce(new Error("Failed to load workspaces"));
|
||||
|
||||
const { result } = renderHook(() => useWorkspaces());
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.error).toBe("Failed to load workspaces");
|
||||
expect(result.current.workspaces).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { FileNode, FileListResponse } from "../api";
|
||||
import { fetchProjectFileList } from "../api";
|
||||
import { fetchWorkspaceFileList } from "../api";
|
||||
|
||||
interface UseProjectFileBrowserReturn {
|
||||
entries: FileNode[];
|
||||
@@ -46,7 +46,8 @@ export function useProjectFileBrowser(rootPath: string, enabled: boolean): UsePr
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response: FileListResponse = await fetchProjectFileList(
|
||||
const response: FileListResponse = await fetchWorkspaceFileList(
|
||||
"project",
|
||||
currentPath === "." ? undefined : currentPath
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { FileContentResponse, SaveFileResponse } from "../api";
|
||||
import { fetchProjectFileContent, saveProjectFileContent } from "../api";
|
||||
import { fetchWorkspaceFileContent, saveWorkspaceFileContent } from "../api";
|
||||
|
||||
interface UseProjectFileEditorReturn {
|
||||
content: string;
|
||||
@@ -56,7 +56,7 @@ export function useProjectFileEditor(
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response: FileContentResponse = await fetchProjectFileContent(filePath!);
|
||||
const response: FileContentResponse = await fetchWorkspaceFileContent("project", filePath!);
|
||||
|
||||
if (!cancelled) {
|
||||
setContentState(response.content);
|
||||
@@ -95,7 +95,7 @@ export function useProjectFileEditor(
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response: SaveFileResponse = await saveProjectFileContent(filePath, content);
|
||||
const response: SaveFileResponse = await saveWorkspaceFileContent("project", filePath, content);
|
||||
setOriginalContent(content);
|
||||
setMtime(response.mtime);
|
||||
} catch (err: any) {
|
||||
|
||||
46
packages/dashboard/app/hooks/useSessionFiles.ts
Normal file
46
packages/dashboard/app/hooks/useSessionFiles.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchSessionFiles } from "../api";
|
||||
|
||||
const ACTIVE_COLUMNS = new Set(["in-progress", "in-review"]);
|
||||
|
||||
interface UseSessionFilesResult {
|
||||
files: string[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function useSessionFiles(taskId: string, worktree: string | undefined, column: string): UseSessionFilesResult {
|
||||
const [files, setFiles] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskId || !worktree || !ACTIVE_COLUMNS.has(column)) {
|
||||
setFiles([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await fetchSessionFiles(taskId);
|
||||
if (!cancelled) {
|
||||
setFiles(result);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setFiles([]);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
}, [taskId, worktree, column]);
|
||||
|
||||
return { files, loading };
|
||||
}
|
||||
92
packages/dashboard/app/hooks/useWorkspaceFileBrowser.ts
Normal file
92
packages/dashboard/app/hooks/useWorkspaceFileBrowser.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { FileNode, FileListResponse } from "../api";
|
||||
import { fetchWorkspaceFileList } from "../api";
|
||||
|
||||
interface UseWorkspaceFileBrowserReturn {
|
||||
entries: FileNode[];
|
||||
currentPath: string;
|
||||
setPath: (path: string) => void;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for browsing files in a selected workspace.
|
||||
*
|
||||
* @param workspace - The workspace identifier ("project" or task ID)
|
||||
* @param enabled - Whether fetching is enabled
|
||||
*/
|
||||
export function useWorkspaceFileBrowser(
|
||||
workspace: string,
|
||||
enabled: boolean,
|
||||
): UseWorkspaceFileBrowserReturn {
|
||||
const [entries, setEntries] = useState<FileNode[]>([]);
|
||||
const [currentPath, setCurrentPath] = useState<string>(".");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setRefreshKey((key) => key + 1);
|
||||
}, []);
|
||||
|
||||
const setPath = useCallback((path: string) => {
|
||||
setCurrentPath(path);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPath(".");
|
||||
setError(null);
|
||||
setEntries([]);
|
||||
}, [workspace]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !workspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function loadFiles() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response: FileListResponse = await fetchWorkspaceFileList(
|
||||
workspace,
|
||||
currentPath === "." ? undefined : currentPath,
|
||||
);
|
||||
|
||||
if (!cancelled) {
|
||||
setEntries(response.entries);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (!cancelled) {
|
||||
setError(err.message || "Failed to load files");
|
||||
setEntries([]);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadFiles();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [workspace, currentPath, enabled, refreshKey]);
|
||||
|
||||
return {
|
||||
entries,
|
||||
currentPath,
|
||||
setPath,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
118
packages/dashboard/app/hooks/useWorkspaceFileEditor.ts
Normal file
118
packages/dashboard/app/hooks/useWorkspaceFileEditor.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { FileContentResponse, SaveFileResponse } from "../api";
|
||||
import { fetchWorkspaceFileContent, saveWorkspaceFileContent } from "../api";
|
||||
|
||||
interface UseWorkspaceFileEditorReturn {
|
||||
content: string;
|
||||
setContent: (content: string) => void;
|
||||
originalContent: string;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
error: string | null;
|
||||
save: () => Promise<void>;
|
||||
hasChanges: boolean;
|
||||
mtime: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for editing a file in a selected workspace.
|
||||
*
|
||||
* @param workspace - The workspace identifier ("project" or task ID)
|
||||
* @param filePath - The selected file path
|
||||
* @param enabled - Whether loading is enabled
|
||||
*/
|
||||
export function useWorkspaceFileEditor(
|
||||
workspace: string,
|
||||
filePath: string | null,
|
||||
enabled: boolean,
|
||||
): UseWorkspaceFileEditorReturn {
|
||||
const [content, setContentState] = useState<string>("");
|
||||
const [originalContent, setOriginalContent] = useState<string>("");
|
||||
const [mtime, setMtime] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const setContent = useCallback((newContent: string) => {
|
||||
setContentState(newContent);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !workspace || !filePath) {
|
||||
setContentState("");
|
||||
setOriginalContent("");
|
||||
setMtime(null);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function loadFile() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response: FileContentResponse = await fetchWorkspaceFileContent(workspace, filePath!);
|
||||
|
||||
if (!cancelled) {
|
||||
setContentState(response.content);
|
||||
setOriginalContent(response.content);
|
||||
setMtime(response.mtime);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (!cancelled) {
|
||||
setError(err.message || "Failed to load file");
|
||||
setContentState("");
|
||||
setOriginalContent("");
|
||||
setMtime(null);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadFile();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [workspace, filePath, enabled]);
|
||||
|
||||
const hasChanges = content !== originalContent;
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (!workspace || !filePath || !hasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response: SaveFileResponse = await saveWorkspaceFileContent(workspace, filePath, content);
|
||||
setOriginalContent(content);
|
||||
setMtime(response.mtime);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to save file");
|
||||
throw err;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [workspace, filePath, content, hasChanges]);
|
||||
|
||||
return {
|
||||
content,
|
||||
setContent,
|
||||
originalContent,
|
||||
loading,
|
||||
saving,
|
||||
error,
|
||||
save,
|
||||
hasChanges,
|
||||
mtime,
|
||||
};
|
||||
}
|
||||
87
packages/dashboard/app/hooks/useWorkspaces.ts
Normal file
87
packages/dashboard/app/hooks/useWorkspaces.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchWorkspaces, type WorkspaceTaskInfo } from "../api";
|
||||
|
||||
export interface WorkspaceInfo {
|
||||
id: string;
|
||||
label: string;
|
||||
title?: string;
|
||||
worktree?: string;
|
||||
kind: "project" | "task";
|
||||
}
|
||||
|
||||
interface UseWorkspacesReturn {
|
||||
projectName: string;
|
||||
workspaces: WorkspaceInfo[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 10000;
|
||||
|
||||
function getProjectName(projectPath: string): string {
|
||||
const normalized = projectPath.replace(/[\\/]+$/, "");
|
||||
const segments = normalized.split(/[\\/]/).filter(Boolean);
|
||||
return segments[segments.length - 1] || projectPath || "Project Root";
|
||||
}
|
||||
|
||||
function mapTaskWorkspace(task: WorkspaceTaskInfo): WorkspaceInfo {
|
||||
return {
|
||||
id: task.id,
|
||||
label: task.id,
|
||||
title: task.title,
|
||||
worktree: task.worktree,
|
||||
kind: "task",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and poll the list of available file browser workspaces.
|
||||
*/
|
||||
export function useWorkspaces(): UseWorkspacesReturn {
|
||||
const [projectName, setProjectName] = useState("Project Root");
|
||||
const [workspaces, setWorkspaces] = useState<WorkspaceInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadWorkspaces() {
|
||||
try {
|
||||
const response = await fetchWorkspaces();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProjectName(getProjectName(response.project));
|
||||
setWorkspaces(response.tasks.map(mapTaskWorkspace));
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
if (!cancelled) {
|
||||
setError(err.message || "Failed to load workspaces");
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadWorkspaces();
|
||||
const intervalId = window.setInterval(() => {
|
||||
void loadWorkspaces();
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
projectName,
|
||||
workspaces,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user