diff --git a/backend/src/__tests__/file-browser.test.ts b/backend/src/__tests__/file-browser.test.ts index 28defa15..13f97455 100644 --- a/backend/src/__tests__/file-browser.test.ts +++ b/backend/src/__tests__/file-browser.test.ts @@ -21,6 +21,7 @@ import { archiveFile, createDirectory, createFile, + renameFile, MAX_FILE_SIZE, PathTraversalError, DirectoryNotFoundError, @@ -2105,3 +2106,185 @@ describe("createFile", () => { expect(fileStat.isFile()).toBe(true); }); }); + +// ============================================================================= +// renameFile Tests +// ============================================================================= + +describe("renameFile", () => { + let testDir: string; + + beforeEach(async () => { + testDir = await createTestDir(); + // Create a nested directory structure for testing + await mkdir(join(testDir, "Projects"), { recursive: true }); + await mkdir(join(testDir, "Notes"), { recursive: true }); + await writeFile(join(testDir, "test-file.md"), "# Test Content"); + await writeFile(join(testDir, "Projects", "my-note.md"), "# My Note"); + }); + + afterEach(async () => { + await cleanupTestDir(testDir); + }); + + test("renames file at vault root", async () => { + const result = await renameFile(testDir, "test-file.md", "renamed-file"); + + expect(result.oldPath).toBe("test-file.md"); + expect(result.newPath).toBe("renamed-file.md"); + + // Verify old file no longer exists + try { + await stat(join(testDir, "test-file.md")); + expect.unreachable("Old file should not exist"); + } catch { + // Expected + } + + // Verify new file exists + const fileStat = await stat(join(testDir, "renamed-file.md")); + expect(fileStat.isFile()).toBe(true); + + // Verify content is preserved + const content = await readFile(join(testDir, "renamed-file.md"), "utf-8"); + expect(content).toBe("# Test Content"); + }); + + test("renames file in nested path", async () => { + const result = await renameFile(testDir, "Projects/my-note.md", "new-name"); + + expect(result.oldPath).toBe("Projects/my-note.md"); + expect(result.newPath).toBe("Projects/new-name.md"); + + // Verify new file exists + const fileStat = await stat(join(testDir, "Projects", "new-name.md")); + expect(fileStat.isFile()).toBe(true); + }); + + test("preserves file extension", async () => { + const result = await renameFile(testDir, "test-file.md", "new-name"); + + expect(result.newPath).toBe("new-name.md"); + expect(result.newPath).toMatch(/\.md$/); + }); + + test("renames directory at vault root", async () => { + const result = await renameFile(testDir, "Projects", "MyProjects"); + + expect(result.oldPath).toBe("Projects"); + expect(result.newPath).toBe("MyProjects"); + + // Verify old directory no longer exists + try { + await stat(join(testDir, "Projects")); + expect.unreachable("Old directory should not exist"); + } catch { + // Expected + } + + // Verify new directory exists + const dirStat = await stat(join(testDir, "MyProjects")); + expect(dirStat.isDirectory()).toBe(true); + + // Verify contents are preserved + const fileStat = await stat(join(testDir, "MyProjects", "my-note.md")); + expect(fileStat.isFile()).toBe(true); + }); + + test("allows alphanumeric names", async () => { + const result = await renameFile(testDir, "test-file.md", "Test123"); + + expect(result.newPath).toBe("Test123.md"); + }); + + test("allows hyphens and underscores", async () => { + const result = await renameFile(testDir, "test-file.md", "my_new-file"); + + expect(result.newPath).toBe("my_new-file.md"); + }); + + test("rejects names with spaces", async () => { + try { + await renameFile(testDir, "test-file.md", "my file"); + expect.unreachable("Should have thrown InvalidFileNameError"); + } catch (error) { + expect(error).toBeInstanceOf(InvalidFileNameError); + } + }); + + test("rejects names with special characters", async () => { + try { + await renameFile(testDir, "test-file.md", "my@file"); + expect.unreachable("Should have thrown InvalidFileNameError"); + } catch (error) { + expect(error).toBeInstanceOf(InvalidFileNameError); + } + + try { + await renameFile(testDir, "test-file.md", "my/file"); + expect.unreachable("Should have thrown InvalidFileNameError"); + } catch (error) { + expect(error).toBeInstanceOf(InvalidFileNameError); + } + }); + + test("rejects empty name", async () => { + try { + await renameFile(testDir, "test-file.md", ""); + expect.unreachable("Should have thrown InvalidFileNameError"); + } catch (error) { + expect(error).toBeInstanceOf(InvalidFileNameError); + } + }); + + test("throws error if source file does not exist", async () => { + try { + await renameFile(testDir, "non-existent.md", "new-name"); + expect.unreachable("Should have thrown FileNotFoundError"); + } catch (error) { + expect(error).toBeInstanceOf(FileNotFoundError); + } + }); + + test("throws error if destination already exists", async () => { + await writeFile(join(testDir, "existing.md"), "content"); + + try { + await renameFile(testDir, "test-file.md", "existing"); + expect.unreachable("Should have thrown FileExistsError"); + } catch (error) { + expect(error).toBeInstanceOf(FileExistsError); + } + }); + + test("rejects path traversal in source path", async () => { + try { + await renameFile(testDir, "../test-file.md", "new-name"); + expect.unreachable("Should have thrown PathTraversalError"); + } catch (error) { + expect(error).toBeInstanceOf(PathTraversalError); + } + }); + + test("rejects symlink as source", async () => { + await symlink(join(testDir, "test-file.md"), join(testDir, "symlink-file.md")); + + try { + await renameFile(testDir, "symlink-file.md", "new-name"); + expect.unreachable("Should have thrown PathTraversalError"); + } catch (error) { + expect(error).toBeInstanceOf(PathTraversalError); + } + }); + + test("handles deeply nested paths", async () => { + await mkdir(join(testDir, "a", "b", "c"), { recursive: true }); + await writeFile(join(testDir, "a", "b", "c", "deep-file.md"), "# Deep"); + + const result = await renameFile(testDir, "a/b/c/deep-file.md", "renamed-deep"); + + expect(result.newPath).toBe("a/b/c/renamed-deep.md"); + const fileStat = await stat(join(testDir, "a", "b", "c", "renamed-deep.md")); + expect(fileStat.isFile()).toBe(true); + }); +}); diff --git a/backend/src/__tests__/reference-updater.test.ts b/backend/src/__tests__/reference-updater.test.ts new file mode 100644 index 00000000..24f18979 --- /dev/null +++ b/backend/src/__tests__/reference-updater.test.ts @@ -0,0 +1,341 @@ +/** + * Reference Updater Tests + * + * Unit tests for updating internal references in markdown files + * when files or directories are renamed. + */ + +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { mkdir, writeFile, readFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { updateReferences } from "../reference-updater"; + +// ============================================================================= +// Test Helpers +// ============================================================================= + +/** + * Creates a unique temporary directory for testing. + */ +async function createTestDir(): Promise { + const testDir = join( + tmpdir(), + `ref-updater-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + await mkdir(testDir, { recursive: true }); + return testDir; +} + +/** + * Recursively removes a test directory. + */ +async function cleanupTestDir(testDir: string): Promise { + try { + await rm(testDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } +} + +// ============================================================================= +// Wikilink Reference Tests +// ============================================================================= + +describe("updateReferences - Wikilinks", () => { + let testDir: string; + + beforeEach(async () => { + testDir = await createTestDir(); + }); + + afterEach(async () => { + await cleanupTestDir(testDir); + }); + + test("updates simple wikilink [[name]]", async () => { + await writeFile( + join(testDir, "index.md"), + "See [[old-file]] for more info." + ); + + const result = await updateReferences(testDir, "old-file.md", "new-file.md", false); + + expect(result.referencesUpdated).toBe(1); + expect(result.filesModified).toBe(1); + + const content = await readFile(join(testDir, "index.md"), "utf-8"); + expect(content).toBe("See [[new-file]] for more info."); + }); + + test("updates wikilink with path [[path/name]]", async () => { + await writeFile( + join(testDir, "index.md"), + "See [[Projects/old-note]] for details." + ); + + const result = await updateReferences(testDir, "Projects/old-note.md", "Projects/new-note.md", false); + + expect(result.referencesUpdated).toBe(1); + + const content = await readFile(join(testDir, "index.md"), "utf-8"); + expect(content).toBe("See [[Projects/new-note]] for details."); + }); + + test("updates multiple wikilinks in same file", async () => { + await writeFile( + join(testDir, "index.md"), + "First link: [[old-file]]\nSecond link: [[old-file]]\nDone." + ); + + const result = await updateReferences(testDir, "old-file.md", "new-file.md", false); + + expect(result.referencesUpdated).toBe(2); + expect(result.filesModified).toBe(1); + + const content = await readFile(join(testDir, "index.md"), "utf-8"); + expect(content).toBe("First link: [[new-file]]\nSecond link: [[new-file]]\nDone."); + }); + + test("updates wikilinks across multiple files", async () => { + await writeFile(join(testDir, "file1.md"), "Link: [[old-file]]"); + await writeFile(join(testDir, "file2.md"), "Another link: [[old-file]]"); + + const result = await updateReferences(testDir, "old-file.md", "new-file.md", false); + + expect(result.referencesUpdated).toBe(2); + expect(result.filesModified).toBe(2); + + const content1 = await readFile(join(testDir, "file1.md"), "utf-8"); + const content2 = await readFile(join(testDir, "file2.md"), "utf-8"); + expect(content1).toBe("Link: [[new-file]]"); + expect(content2).toBe("Another link: [[new-file]]"); + }); + + test("does not modify unrelated wikilinks", async () => { + await writeFile( + join(testDir, "index.md"), + "See [[old-file]] and [[other-file]] for info." + ); + + await updateReferences(testDir, "old-file.md", "new-file.md", false); + + const content = await readFile(join(testDir, "index.md"), "utf-8"); + expect(content).toBe("See [[new-file]] and [[other-file]] for info."); + }); +}); + +// ============================================================================= +// Markdown Link Reference Tests +// ============================================================================= + +describe("updateReferences - Markdown Links", () => { + let testDir: string; + + beforeEach(async () => { + testDir = await createTestDir(); + }); + + afterEach(async () => { + await cleanupTestDir(testDir); + }); + + test("updates markdown link [text](path)", async () => { + await writeFile( + join(testDir, "index.md"), + "See [this file](old-file.md) for more." + ); + + const result = await updateReferences(testDir, "old-file.md", "new-file.md", false); + + expect(result.referencesUpdated).toBe(1); + + const content = await readFile(join(testDir, "index.md"), "utf-8"); + expect(content).toBe("See [this file](new-file.md) for more."); + }); + + test("updates markdown link with full path", async () => { + await writeFile( + join(testDir, "index.md"), + "See [details](Projects/old-note.md) here." + ); + + const result = await updateReferences(testDir, "Projects/old-note.md", "Projects/new-note.md", false); + + expect(result.referencesUpdated).toBe(1); + + const content = await readFile(join(testDir, "index.md"), "utf-8"); + expect(content).toBe("See [details](Projects/new-note.md) here."); + }); + + test("preserves link text when updating path", async () => { + await writeFile( + join(testDir, "index.md"), + "Check out [my important document](old-file.md)!" + ); + + await updateReferences(testDir, "old-file.md", "new-file.md", false); + + const content = await readFile(join(testDir, "index.md"), "utf-8"); + expect(content).toBe("Check out [my important document](new-file.md)!"); + }); + + test("updates multiple markdown links", async () => { + await writeFile( + join(testDir, "index.md"), + "[First](old-file.md) and [Second](old-file.md)" + ); + + const result = await updateReferences(testDir, "old-file.md", "new-file.md", false); + + expect(result.referencesUpdated).toBe(2); + + const content = await readFile(join(testDir, "index.md"), "utf-8"); + expect(content).toBe("[First](new-file.md) and [Second](new-file.md)"); + }); +}); + +// ============================================================================= +// Directory Rename Reference Tests +// ============================================================================= + +describe("updateReferences - Directory Rename", () => { + let testDir: string; + + beforeEach(async () => { + testDir = await createTestDir(); + }); + + afterEach(async () => { + await cleanupTestDir(testDir); + }); + + test("updates wikilinks to files inside renamed directory", async () => { + await writeFile( + join(testDir, "index.md"), + "See [[OldDir/some-file]] for details." + ); + + const result = await updateReferences(testDir, "OldDir", "NewDir", true); + + expect(result.referencesUpdated).toBe(1); + + const content = await readFile(join(testDir, "index.md"), "utf-8"); + expect(content).toBe("See [[NewDir/some-file]] for details."); + }); + + test("updates markdown links to files inside renamed directory", async () => { + await writeFile( + join(testDir, "index.md"), + "See [doc](OldDir/file.md) here." + ); + + const result = await updateReferences(testDir, "OldDir", "NewDir", true); + + expect(result.referencesUpdated).toBe(1); + + const content = await readFile(join(testDir, "index.md"), "utf-8"); + expect(content).toBe("See [doc](NewDir/file.md) here."); + }); + + test("updates deeply nested directory references", async () => { + await writeFile( + join(testDir, "index.md"), + "Link: [[old-parent/child/file]]\nAnother: [doc](old-parent/child/other.md)" + ); + + const result = await updateReferences(testDir, "old-parent", "new-parent", true); + + expect(result.referencesUpdated).toBe(2); + + const content = await readFile(join(testDir, "index.md"), "utf-8"); + expect(content).toContain("[[new-parent/child/file]]"); + expect(content).toContain("[doc](new-parent/child/other.md)"); + }); +}); + +// ============================================================================= +// Edge Cases +// ============================================================================= + +describe("updateReferences - Edge Cases", () => { + let testDir: string; + + beforeEach(async () => { + testDir = await createTestDir(); + }); + + afterEach(async () => { + await cleanupTestDir(testDir); + }); + + test("handles files in nested directories", async () => { + await mkdir(join(testDir, "docs", "guides"), { recursive: true }); + await writeFile( + join(testDir, "docs", "guides", "index.md"), + "Link: [[old-file]]" + ); + + const result = await updateReferences(testDir, "old-file.md", "new-file.md", false); + + expect(result.referencesUpdated).toBe(1); + + const content = await readFile(join(testDir, "docs", "guides", "index.md"), "utf-8"); + expect(content).toBe("Link: [[new-file]]"); + }); + + test("skips hidden files", async () => { + await writeFile(join(testDir, ".hidden.md"), "Link: [[old-file]]"); + await writeFile(join(testDir, "visible.md"), "Link: [[old-file]]"); + + const result = await updateReferences(testDir, "old-file.md", "new-file.md", false); + + // Only the visible file should be updated + expect(result.filesModified).toBe(1); + + // Hidden file should be unchanged + const hidden = await readFile(join(testDir, ".hidden.md"), "utf-8"); + expect(hidden).toBe("Link: [[old-file]]"); + }); + + test("skips hidden directories", async () => { + await mkdir(join(testDir, ".hidden")); + await writeFile(join(testDir, ".hidden", "file.md"), "Link: [[old-file]]"); + await writeFile(join(testDir, "visible.md"), "Link: [[old-file]]"); + + const result = await updateReferences(testDir, "old-file.md", "new-file.md", false); + + expect(result.filesModified).toBe(1); + }); + + test("returns zero counts when no references found", async () => { + await writeFile(join(testDir, "index.md"), "No references here."); + + const result = await updateReferences(testDir, "old-file.md", "new-file.md", false); + + expect(result.referencesUpdated).toBe(0); + expect(result.filesModified).toBe(0); + }); + + test("handles empty vault", async () => { + const result = await updateReferences(testDir, "old-file.md", "new-file.md", false); + + expect(result.referencesUpdated).toBe(0); + expect(result.filesModified).toBe(0); + }); + + test("handles special regex characters in file names", async () => { + await writeFile( + join(testDir, "index.md"), + "Link: [[file-with-dash]]" + ); + + const result = await updateReferences(testDir, "file-with-dash.md", "new-name.md", false); + + expect(result.referencesUpdated).toBe(1); + + const content = await readFile(join(testDir, "index.md"), "utf-8"); + expect(content).toBe("Link: [[new-name]]"); + }); +}); diff --git a/backend/src/file-browser.ts b/backend/src/file-browser.ts index bab14cc9..b7450e0f 100644 --- a/backend/src/file-browser.ts +++ b/backend/src/file-browser.ts @@ -868,3 +868,118 @@ export async function createFile( return newFileRelative; } + +// ============================================================================= +// File/Directory Renaming +// ============================================================================= + +/** + * Result of renaming a file or directory. + */ +export interface RenameResult { + /** The original path */ + oldPath: string; + /** The new path after rename */ + newPath: string; +} + +/** + * Renames a file or directory within the vault. + * For files, the extension is preserved automatically. + * + * @param vaultPath - Absolute path to the vault root (content root) + * @param relativePath - Current path relative to vault root + * @param newName - New name (alphanumeric with - and _ only, without extension for files) + * @returns RenameResult with old and new paths + * @throws InvalidFileNameError if name contains invalid characters + * @throws FileNotFoundError if file/directory does not exist + * @throws FileExistsError if destination already exists + * @throws PathTraversalError if path escapes vault boundary + */ +export async function renameFile( + vaultPath: string, + relativePath: string, + newName: string +): Promise { + log.info(`Renaming: ${relativePath} to ${newName}`); + + // Validate new name (without extension) + if (!FILE_NAME_PATTERN.test(newName)) { + throw new InvalidFileNameError( + `Name "${newName}" contains invalid characters. Only alphanumeric, hyphen, and underscore are allowed.` + ); + } + + // Validate path is within vault + const targetPath = await validatePath(vaultPath, relativePath); + + // Check if target exists and get its type + let isDirectory: boolean; + try { + const stats = await lstat(targetPath); + + if (stats.isSymbolicLink()) { + log.warn(`Symlink rejected for rename: ${relativePath}`); + throw new PathTraversalError( + `Path "${relativePath}" is a symbolic link and cannot be renamed` + ); + } + + isDirectory = stats.isDirectory(); + } catch (error) { + if (error instanceof FileBrowserError) { + throw error; + } + throw new FileNotFoundError(`Path "${relativePath}" does not exist`); + } + + // Build the new path + // For files, preserve the extension + const parentPath = relativePath.includes("/") + ? relativePath.substring(0, relativePath.lastIndexOf("/")) + : ""; + const oldName = basename(relativePath); + const extension = isDirectory ? "" : extname(oldName); + const newFileName = isDirectory ? newName : `${newName}${extension}`; + + const newRelativePath = parentPath === "" + ? newFileName + : `${parentPath}/${newFileName}`; + + const newAbsolutePath = parentPath === "" + ? join(vaultPath, newFileName) + : join(vaultPath, parentPath, newFileName); + + // Validate the new path is within vault (defense in depth) + if (!(await isPathWithinVault(vaultPath, newAbsolutePath))) { + throw new PathTraversalError( + `Path "${newRelativePath}" would escape the vault boundary` + ); + } + + // Check if destination already exists (and it's not the same file with different case) + try { + await stat(newAbsolutePath); + // If we get here, something exists at this path + // Allow rename if it's the same path (case change on case-insensitive FS) + if (newRelativePath.toLowerCase() !== relativePath.toLowerCase()) { + throw new FileExistsError( + `Destination "${newRelativePath}" already exists` + ); + } + } catch (error) { + if (error instanceof FileExistsError) { + throw error; + } + // Good - destination doesn't exist, we can rename + } + + // Perform the rename + await rename(targetPath, newAbsolutePath); + log.info(`Successfully renamed ${relativePath} to ${newRelativePath}`); + + return { + oldPath: relativePath, + newPath: newRelativePath, + }; +} diff --git a/backend/src/handlers/__tests__/browser-handlers.test.ts b/backend/src/handlers/__tests__/browser-handlers.test.ts index 6ea0d64f..a67b6962 100644 --- a/backend/src/handlers/__tests__/browser-handlers.test.ts +++ b/backend/src/handlers/__tests__/browser-handlers.test.ts @@ -11,7 +11,9 @@ import { describe, it, expect, beforeEach, mock } from "bun:test"; import type { VaultInfo, ServerMessage } from "@memory-loop/shared"; import type { HandlerContext, ConnectionState, RequiredHandlerDependencies } from "../types.js"; -import { handleCreateDirectory } from "../browser-handlers.js"; +import { handleCreateDirectory, handleRenameFile } from "../browser-handlers.js"; +import type { RenameResult } from "../../file-browser.js"; +import type { ReferenceUpdateResult } from "../../reference-updater.js"; // ============================================================================= // Test Fixtures @@ -72,6 +74,8 @@ function createMockDeps( archiveFile: () => Promise.resolve({ originalPath: "", archivePath: "" }), createDirectory: createDirectoryFn, createFile: () => Promise.resolve(""), + renameFile: () => Promise.resolve({ oldPath: "", newPath: "" }), + updateReferences: () => Promise.resolve({ filesModified: 0, referencesUpdated: 0 }), getInspiration: () => Promise.resolve({ contextual: null, quote: { text: "", attribution: "" } }), getAllTasks: () => Promise.resolve({ tasks: [], incomplete: 0, total: 0 }), toggleTask: () => Promise.resolve({ success: true }), @@ -248,3 +252,213 @@ describe("handleCreateDirectory", () => { } }); }); + +// ============================================================================= +// handleRenameFile Tests +// ============================================================================= + +function createMockDepsWithRename( + renameFn: (vaultPath: string, relativePath: string, newName: string) => Promise, + updateRefsFn: (vaultPath: string, oldPath: string, newPath: string, isDirectory: boolean) => Promise +): RequiredHandlerDependencies { + return { + captureToDaily: () => Promise.resolve({ success: true, timestamp: "", notePath: "" }), + getRecentNotes: () => Promise.resolve([]), + listDirectory: () => Promise.resolve([]), + readMarkdownFile: () => Promise.resolve({ content: "", truncated: false }), + writeMarkdownFile: () => Promise.resolve(), + deleteFile: () => Promise.resolve(), + archiveFile: () => Promise.resolve({ originalPath: "", archivePath: "" }), + createDirectory: () => Promise.resolve(""), + createFile: () => Promise.resolve(""), + renameFile: renameFn, + updateReferences: updateRefsFn, + getInspiration: () => Promise.resolve({ contextual: null, quote: { text: "", attribution: "" } }), + getAllTasks: () => Promise.resolve({ tasks: [], incomplete: 0, total: 0 }), + toggleTask: () => Promise.resolve({ success: true }), + getRecentSessions: () => Promise.resolve([]), + loadVaultConfig: () => Promise.resolve({}), + parseFrontmatter: () => ({ data: {}, content: "" }), + }; +} + +describe("handleRenameFile", () => { + it("should send file_renamed message on success", async () => { + const mockRename = mock(() => Promise.resolve({ oldPath: "old-file.md", newPath: "new-file.md" })); + const mockUpdateRefs = mock(() => Promise.resolve({ filesModified: 2, referencesUpdated: 5 })); + const ctx = createMockContext(createMockDepsWithRename(mockRename, mockUpdateRefs)); + + await handleRenameFile(ctx, "old-file.md", "new-file"); + + expect(sentMessages.length).toBe(1); + expect(sentMessages[0].type).toBe("file_renamed"); + if (sentMessages[0].type === "file_renamed") { + expect(sentMessages[0].oldPath).toBe("old-file.md"); + expect(sentMessages[0].newPath).toBe("new-file.md"); + expect(sentMessages[0].referencesUpdated).toBe(5); + } + }); + + it("should call renameFile with correct parameters", async () => { + const mockRename = mock(() => Promise.resolve({ oldPath: "docs/file.md", newPath: "docs/renamed.md" })); + const mockUpdateRefs = mock(() => Promise.resolve({ filesModified: 0, referencesUpdated: 0 })); + const ctx = createMockContext(createMockDepsWithRename(mockRename, mockUpdateRefs)); + + await handleRenameFile(ctx, "docs/file.md", "renamed"); + + expect(mockRename).toHaveBeenCalledTimes(1); + expect(mockRename).toHaveBeenCalledWith("/test/vault", "docs/file.md", "renamed"); + }); + + it("should call updateReferences after rename", async () => { + const mockRename = mock(() => Promise.resolve({ oldPath: "old.md", newPath: "new.md" })); + const mockUpdateRefs = mock(() => Promise.resolve({ filesModified: 1, referencesUpdated: 3 })); + const ctx = createMockContext(createMockDepsWithRename(mockRename, mockUpdateRefs)); + + await handleRenameFile(ctx, "old.md", "new"); + + expect(mockUpdateRefs).toHaveBeenCalledTimes(1); + expect(mockUpdateRefs).toHaveBeenCalledWith("/test/vault", "old.md", "new.md", false); + }); + + it("should detect directory rename and pass isDirectory=true to updateReferences", async () => { + const mockRename = mock(() => Promise.resolve({ oldPath: "OldFolder", newPath: "NewFolder" })); + const mockUpdateRefs = mock(() => Promise.resolve({ filesModified: 0, referencesUpdated: 0 })); + const ctx = createMockContext(createMockDepsWithRename(mockRename, mockUpdateRefs)); + + await handleRenameFile(ctx, "OldFolder", "NewFolder"); + + expect(mockUpdateRefs).toHaveBeenCalledWith("/test/vault", "OldFolder", "NewFolder", true); + }); + + it("should send error when no vault is selected", async () => { + const mockRename = mock(() => Promise.resolve({ oldPath: "", newPath: "" })); + const mockUpdateRefs = mock(() => Promise.resolve({ filesModified: 0, referencesUpdated: 0 })); + const noVaultState: ConnectionState = { + ...mockState, + currentVault: null, + }; + const ctx = createMockContext(createMockDepsWithRename(mockRename, mockUpdateRefs), noVaultState); + + await handleRenameFile(ctx, "file.md", "new-name"); + + expect(mockRename).not.toHaveBeenCalled(); + expect(sentMessages.length).toBe(1); + expect(sentMessages[0].type).toBe("error"); + }); + + it("should handle InvalidFileNameError (VALIDATION_ERROR)", async () => { + const error = new Error("Invalid name"); + error.name = "FileBrowserError"; + Object.assign(error, { code: "VALIDATION_ERROR" }); + const mockRename = mock(() => Promise.reject(error)); + const mockUpdateRefs = mock(() => Promise.resolve({ filesModified: 0, referencesUpdated: 0 })); + const ctx = createMockContext(createMockDepsWithRename(mockRename, mockUpdateRefs)); + + await handleRenameFile(ctx, "file.md", "invalid name!"); + + expect(sentMessages.length).toBe(1); + expect(sentMessages[0].type).toBe("error"); + if (sentMessages[0].type === "error") { + expect(sentMessages[0].code).toBe("VALIDATION_ERROR"); + } + }); + + it("should handle FileNotFoundError", async () => { + const error = new Error("File not found"); + error.name = "FileBrowserError"; + Object.assign(error, { code: "FILE_NOT_FOUND" }); + const mockRename = mock(() => Promise.reject(error)); + const mockUpdateRefs = mock(() => Promise.resolve({ filesModified: 0, referencesUpdated: 0 })); + const ctx = createMockContext(createMockDepsWithRename(mockRename, mockUpdateRefs)); + + await handleRenameFile(ctx, "nonexistent.md", "new-name"); + + expect(sentMessages.length).toBe(1); + expect(sentMessages[0].type).toBe("error"); + if (sentMessages[0].type === "error") { + expect(sentMessages[0].code).toBe("FILE_NOT_FOUND"); + } + }); + + it("should handle FileExistsError", async () => { + const error = new Error("Destination already exists"); + error.name = "FileBrowserError"; + Object.assign(error, { code: "VALIDATION_ERROR" }); + const mockRename = mock(() => Promise.reject(error)); + const mockUpdateRefs = mock(() => Promise.resolve({ filesModified: 0, referencesUpdated: 0 })); + const ctx = createMockContext(createMockDepsWithRename(mockRename, mockUpdateRefs)); + + await handleRenameFile(ctx, "file.md", "existing"); + + expect(sentMessages.length).toBe(1); + expect(sentMessages[0].type).toBe("error"); + if (sentMessages[0].type === "error") { + expect(sentMessages[0].code).toBe("VALIDATION_ERROR"); + } + }); + + it("should handle PathTraversalError", async () => { + const error = new Error("Path traversal detected"); + error.name = "FileBrowserError"; + Object.assign(error, { code: "PATH_TRAVERSAL" }); + const mockRename = mock(() => Promise.reject(error)); + const mockUpdateRefs = mock(() => Promise.resolve({ filesModified: 0, referencesUpdated: 0 })); + const ctx = createMockContext(createMockDepsWithRename(mockRename, mockUpdateRefs)); + + await handleRenameFile(ctx, "../outside.md", "new-name"); + + expect(sentMessages.length).toBe(1); + expect(sentMessages[0].type).toBe("error"); + if (sentMessages[0].type === "error") { + expect(sentMessages[0].code).toBe("PATH_TRAVERSAL"); + } + }); + + it("should handle generic errors with INTERNAL_ERROR code", async () => { + const mockRename = mock(() => Promise.reject(new Error("Unexpected error"))); + const mockUpdateRefs = mock(() => Promise.resolve({ filesModified: 0, referencesUpdated: 0 })); + const ctx = createMockContext(createMockDepsWithRename(mockRename, mockUpdateRefs)); + + await handleRenameFile(ctx, "file.md", "new-name"); + + expect(sentMessages.length).toBe(1); + expect(sentMessages[0].type).toBe("error"); + if (sentMessages[0].type === "error") { + expect(sentMessages[0].code).toBe("INTERNAL_ERROR"); + expect(sentMessages[0].message).toBe("Unexpected error"); + } + }); + + it("should handle non-Error objects in catch", async () => { + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + const mockRename = mock(() => Promise.reject("string error")); + const mockUpdateRefs = mock(() => Promise.resolve({ filesModified: 0, referencesUpdated: 0 })); + const ctx = createMockContext(createMockDepsWithRename(mockRename, mockUpdateRefs)); + + await handleRenameFile(ctx, "file.md", "new-name"); + + expect(sentMessages.length).toBe(1); + expect(sentMessages[0].type).toBe("error"); + if (sentMessages[0].type === "error") { + expect(sentMessages[0].code).toBe("INTERNAL_ERROR"); + expect(sentMessages[0].message).toBe("Failed to rename"); + } + }); + + it("should rename file in nested directory", async () => { + const mockRename = mock(() => Promise.resolve({ oldPath: "docs/notes/file.md", newPath: "docs/notes/renamed.md" })); + const mockUpdateRefs = mock(() => Promise.resolve({ filesModified: 3, referencesUpdated: 7 })); + const ctx = createMockContext(createMockDepsWithRename(mockRename, mockUpdateRefs)); + + await handleRenameFile(ctx, "docs/notes/file.md", "renamed"); + + expect(sentMessages.length).toBe(1); + expect(sentMessages[0].type).toBe("file_renamed"); + if (sentMessages[0].type === "file_renamed") { + expect(sentMessages[0].oldPath).toBe("docs/notes/file.md"); + expect(sentMessages[0].newPath).toBe("docs/notes/renamed.md"); + expect(sentMessages[0].referencesUpdated).toBe(7); + } + }); +}); diff --git a/backend/src/handlers/__tests__/sync-handlers.test.ts b/backend/src/handlers/__tests__/sync-handlers.test.ts index 09ecf68d..f56f8bd0 100644 --- a/backend/src/handlers/__tests__/sync-handlers.test.ts +++ b/backend/src/handlers/__tests__/sync-handlers.test.ts @@ -146,6 +146,8 @@ const stubHandlerDeps: RequiredHandlerDependencies = { archiveFile: () => Promise.resolve({ originalPath: "", archivePath: "" }), createDirectory: () => Promise.resolve(""), createFile: () => Promise.resolve(""), + renameFile: () => Promise.resolve({ oldPath: "", newPath: "" }), + updateReferences: () => Promise.resolve({ filesModified: 0, referencesUpdated: 0 }), getInspiration: () => Promise.resolve({ contextual: null, quote: { text: "", attribution: "" } }), getAllTasks: () => Promise.resolve({ tasks: [], incomplete: 0, total: 0 }), toggleTask: () => Promise.resolve({ success: true }), diff --git a/backend/src/handlers/browser-handlers.ts b/backend/src/handlers/browser-handlers.ts index 58bd2c49..b8d48678 100644 --- a/backend/src/handlers/browser-handlers.ts +++ b/backend/src/handlers/browser-handlers.ts @@ -258,3 +258,57 @@ export async function handleCreateFile( } } } + +/** + * Handles rename_file message. + * Renames a file or directory in the selected vault and updates references. + */ +export async function handleRenameFile( + ctx: HandlerContext, + path: string, + newName: string +): Promise { + log.info(`Renaming: ${path} to ${newName}`); + + if (!requireVault(ctx)) { + log.warn("No vault selected for rename"); + return; + } + + try { + // First, rename the file/directory + const renameResult = await ctx.deps.renameFile(ctx.state.currentVault.contentRoot, path, newName); + + // Determine if it was a directory (check if old path had no extension or was a known directory) + // We can infer this from the result - if newPath has an extension, it was a file + const hasExtension = renameResult.newPath.includes(".") && + !renameResult.newPath.endsWith("/") && + renameResult.newPath.lastIndexOf(".") > renameResult.newPath.lastIndexOf("/"); + const isDirectory = !hasExtension; + + // Then, update references in all markdown files + const refResult = await ctx.deps.updateReferences( + ctx.state.currentVault.contentRoot, + renameResult.oldPath, + renameResult.newPath, + isDirectory + ); + + log.info(`Renamed ${path} to ${renameResult.newPath}, updated ${refResult.referencesUpdated} references`); + ctx.send({ + type: "file_renamed", + oldPath: renameResult.oldPath, + newPath: renameResult.newPath, + referencesUpdated: refResult.referencesUpdated, + }); + } catch (error) { + log.error("Rename failed", error); + if (isFileBrowserError(error)) { + ctx.sendError(error.code, error.message); + } else { + const message = + error instanceof Error ? error.message : "Failed to rename"; + ctx.sendError("INTERNAL_ERROR", message); + } + } +} diff --git a/backend/src/handlers/types.ts b/backend/src/handlers/types.ts index 5f95af5b..d3ee5bf0 100644 --- a/backend/src/handlers/types.ts +++ b/backend/src/handlers/types.ts @@ -19,7 +19,8 @@ import type { WidgetEngine, FileWatcher } from "../widgets/index.js"; import type { HealthCollector } from "../health-collector.js"; import type { ActiveMeeting } from "../meeting-capture.js"; import type { VaultConfig } from "../vault-config.js"; -import type { ArchiveResult } from "../file-browser.js"; +import type { ArchiveResult, RenameResult } from "../file-browser.js"; +import type { ReferenceUpdateResult } from "../reference-updater.js"; // ============================================================================= // Handler Dependencies (Injectable for Testing) @@ -111,6 +112,8 @@ export interface HandlerDependencies { archiveFile?: (vaultPath: string, relativePath: string, archiveRoot: string) => Promise; createDirectory?: (vaultPath: string, parentPath: string, name: string) => Promise; createFile?: (vaultPath: string, parentPath: string, name: string) => Promise; + renameFile?: (vaultPath: string, relativePath: string, newName: string) => Promise; + updateReferences?: (vaultPath: string, oldPath: string, newPath: string, isDirectory: boolean) => Promise; // Inspiration manager getInspiration?: (vault: VaultInfo) => Promise; @@ -224,6 +227,8 @@ export interface RequiredHandlerDependencies { archiveFile: (vaultPath: string, relativePath: string, archiveRoot: string) => Promise; createDirectory: (vaultPath: string, parentPath: string, name: string) => Promise; createFile: (vaultPath: string, parentPath: string, name: string) => Promise; + renameFile: (vaultPath: string, relativePath: string, newName: string) => Promise; + updateReferences: (vaultPath: string, oldPath: string, newPath: string, isDirectory: boolean) => Promise; getInspiration: (vault: VaultInfo) => Promise; getAllTasks: ( vaultPath: string, diff --git a/backend/src/reference-updater.ts b/backend/src/reference-updater.ts new file mode 100644 index 00000000..78214550 --- /dev/null +++ b/backend/src/reference-updater.ts @@ -0,0 +1,242 @@ +/** + * Reference Updater + * + * Updates internal references in markdown files when a file or directory is renamed. + * Supports two reference formats: + * - Wikilinks: [[file-name]] or [[path/to/file-name]] + * - Markdown links: [text](path/to/file-name.md) + */ + +import { readdir, readFile, writeFile, lstat } from "node:fs/promises"; +import { join, basename, extname } from "node:path"; +import { createLogger } from "./logger"; + +const log = createLogger("ReferenceUpdater"); + +/** + * Result of updating references across the vault. + */ +export interface ReferenceUpdateResult { + /** Number of files that were modified */ + filesModified: number; + /** Total number of references updated */ + referencesUpdated: number; +} + +/** + * Escapes special regex characters in a string. + */ +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Gets the name without extension from a path. + */ +function getNameWithoutExtension(filePath: string): string { + const name = basename(filePath); + const ext = extname(name); + return ext ? name.slice(0, -ext.length) : name; +} + +/** + * Recursively finds all markdown files in a directory. + */ +async function findMarkdownFiles(dirPath: string): Promise { + const files: string[] = []; + + async function scanDir(currentPath: string): Promise { + const entries = await readdir(currentPath, { withFileTypes: true }); + + for (const entry of entries) { + // Skip hidden files and directories + if (entry.name.startsWith(".")) { + continue; + } + + const entryPath = join(currentPath, entry.name); + + try { + const stats = await lstat(entryPath); + + if (stats.isSymbolicLink()) { + continue; // Skip symlinks + } + + if (stats.isDirectory()) { + await scanDir(entryPath); + } else if (stats.isFile() && entry.name.endsWith(".md")) { + files.push(entryPath); + } + } catch { + // Skip entries we can't stat + } + } + } + + await scanDir(dirPath); + return files; +} + +/** + * Updates references in a single markdown file. + * + * @param filePath - Absolute path to the markdown file + * @param oldPath - Old relative path (from content root) + * @param newPath - New relative path (from content root) + * @param isDirectory - Whether the renamed item is a directory + * @returns Number of references updated in this file + */ +async function updateFileReferences( + filePath: string, + oldPath: string, + newPath: string, + isDirectory: boolean +): Promise { + const content = await readFile(filePath, "utf-8"); + let updatedContent = content; + let updateCount = 0; + + const oldName = getNameWithoutExtension(oldPath); + const newName = getNameWithoutExtension(newPath); + const oldBasename = basename(oldPath); + const newBasename = basename(newPath); + + // Pattern 1: Wikilinks [[name]] or [[path/name]] + // Match [[old-name]] (just the name, no path) + const wikiLinkNamePattern = new RegExp( + `\\[\\[${escapeRegex(oldName)}\\]\\]`, + "g" + ); + const wikiLinkNameMatches = updatedContent.match(wikiLinkNamePattern); + if (wikiLinkNameMatches) { + updatedContent = updatedContent.replace(wikiLinkNamePattern, `[[${newName}]]`); + updateCount += wikiLinkNameMatches.length; + } + + // Match [[path/old-name]] (full path without extension) + const wikiLinkPathPattern = new RegExp( + `\\[\\[${escapeRegex(oldPath.replace(/\.[^.]+$/, ""))}\\]\\]`, + "g" + ); + const wikiLinkPathMatches = updatedContent.match(wikiLinkPathPattern); + if (wikiLinkPathMatches) { + updatedContent = updatedContent.replace( + wikiLinkPathPattern, + `[[${newPath.replace(/\.[^.]+$/, "")}]]` + ); + updateCount += wikiLinkPathMatches.length; + } + + // For directories, also match wikilinks that reference files inside the directory + if (isDirectory) { + // Match [[old-dir/anything]] and replace with [[new-dir/anything]] + const dirWikiLinkPattern = new RegExp( + `\\[\\[${escapeRegex(oldPath)}/([^\\]]+)\\]\\]`, + "g" + ); + const dirMatches = updatedContent.match(dirWikiLinkPattern); + if (dirMatches) { + updatedContent = updatedContent.replace( + dirWikiLinkPattern, + `[[${newPath}/$1]]` + ); + updateCount += dirMatches.length; + } + } + + // Pattern 2: Markdown links [text](path/name.ext) + // Match [text](old-path) where old-path is the full path with extension + const mdLinkPattern = new RegExp( + `\\[([^\\]]+)\\]\\(${escapeRegex(oldPath)}\\)`, + "g" + ); + const mdLinkMatches = updatedContent.match(mdLinkPattern); + if (mdLinkMatches) { + updatedContent = updatedContent.replace( + mdLinkPattern, + `[$1](${newPath})` + ); + updateCount += mdLinkMatches.length; + } + + // Also match markdown links with just the filename (for files in same directory) + const mdLinkNamePattern = new RegExp( + `\\[([^\\]]+)\\]\\(${escapeRegex(oldBasename)}\\)`, + "g" + ); + const mdLinkNameMatches = updatedContent.match(mdLinkNamePattern); + if (mdLinkNameMatches) { + updatedContent = updatedContent.replace( + mdLinkNamePattern, + `[$1](${newBasename})` + ); + updateCount += mdLinkNameMatches.length; + } + + // For directories, update markdown links to files inside + if (isDirectory) { + const dirMdLinkPattern = new RegExp( + `\\[([^\\]]+)\\]\\(${escapeRegex(oldPath)}/([^)]+)\\)`, + "g" + ); + const dirMdMatches = updatedContent.match(dirMdLinkPattern); + if (dirMdMatches) { + updatedContent = updatedContent.replace( + dirMdLinkPattern, + `[$1](${newPath}/$2)` + ); + updateCount += dirMdMatches.length; + } + } + + // Write back if changed + if (updateCount > 0) { + await writeFile(filePath, updatedContent, "utf-8"); + log.debug(`Updated ${updateCount} references in ${filePath}`); + } + + return updateCount; +} + +/** + * Updates all references to a renamed file or directory across the vault. + * + * @param vaultPath - Absolute path to the vault content root + * @param oldPath - Old relative path (from content root) + * @param newPath - New relative path (from content root) + * @param isDirectory - Whether the renamed item is a directory + * @returns Result with counts of files modified and references updated + */ +export async function updateReferences( + vaultPath: string, + oldPath: string, + newPath: string, + isDirectory: boolean +): Promise { + log.info(`Updating references: ${oldPath} -> ${newPath} (isDirectory: ${isDirectory})`); + + // Find all markdown files in the vault + const mdFiles = await findMarkdownFiles(vaultPath); + log.debug(`Found ${mdFiles.length} markdown files to scan`); + + let filesModified = 0; + let referencesUpdated = 0; + + // Update references in each file + for (const filePath of mdFiles) { + try { + const count = await updateFileReferences(filePath, oldPath, newPath, isDirectory); + if (count > 0) { + filesModified++; + referencesUpdated += count; + } + } catch (error) { + log.warn(`Failed to update references in ${filePath}:`, error); + // Continue with other files + } + } + + log.info(`Updated ${referencesUpdated} references in ${filesModified} files`); + return { filesModified, referencesUpdated }; +} diff --git a/backend/src/websocket-handler.ts b/backend/src/websocket-handler.ts index 7a0f7d99..a916c929 100644 --- a/backend/src/websocket-handler.ts +++ b/backend/src/websocket-handler.ts @@ -94,7 +94,9 @@ import { archiveFile as defaultArchiveFile, createDirectory as defaultCreateDirectory, createFile as defaultCreateFile, + renameFile as defaultRenameFile, } from "./file-browser.js"; +import { updateReferences as defaultUpdateReferences } from "./reference-updater.js"; import { getInspiration as defaultGetInspiration } from "./inspiration-manager.js"; import { getAllTasks as defaultGetAllTasks, @@ -165,6 +167,7 @@ import { handleArchiveFile, handleCreateDirectory, handleCreateFile, + handleRenameFile, } from "./handlers/browser-handlers.js"; import { @@ -295,6 +298,8 @@ export class WebSocketHandler { archiveFile: hd.archiveFile ?? defaultArchiveFile, createDirectory: hd.createDirectory ?? defaultCreateDirectory, createFile: hd.createFile ?? defaultCreateFile, + renameFile: hd.renameFile ?? defaultRenameFile, + updateReferences: hd.updateReferences ?? defaultUpdateReferences, getInspiration: hd.getInspiration ?? defaultGetInspiration, getAllTasks: hd.getAllTasks ?? defaultGetAllTasks, toggleTask: hd.toggleTask ?? defaultToggleTask, @@ -661,6 +666,9 @@ export class WebSocketHandler { case "create_file": await handleCreateFile(ctx, message.path, message.name); break; + case "rename_file": + await handleRenameFile(ctx, message.path, message.newName); + break; // Search handlers (extracted) case "search_files": diff --git a/frontend/src/components/BrowseMode.tsx b/frontend/src/components/BrowseMode.tsx index 4335341d..4827b11b 100644 --- a/frontend/src/components/BrowseMode.tsx +++ b/frontend/src/components/BrowseMode.tsx @@ -303,6 +303,26 @@ export function BrowseMode(): React.ReactNode { break; } + case "file_renamed": { + // File/directory renamed - refresh the parent directory + const newPath = lastMessage.newPath; + const parentPath = newPath.includes("/") + ? newPath.substring(0, newPath.lastIndexOf("/")) + : ""; + // Refresh the parent directory listing + sendMessage({ type: "list_directory", path: parentPath }); + // If the renamed file was currently being viewed, update the path + if (browser.currentPath === lastMessage.oldPath) { + setCurrentPath(newPath); + } + // If a file inside a renamed directory was being viewed, update the path + else if (browser.currentPath.startsWith(lastMessage.oldPath + "/")) { + const relativePath = browser.currentPath.substring(lastMessage.oldPath.length); + setCurrentPath(newPath + relativePath); + } + break; + } + case "tasks": // Task list received from server setTasks(lastMessage.tasks); @@ -375,6 +395,14 @@ export function BrowseMode(): React.ReactNode { [sendMessage] ); + // Handle file/directory rename from FileTree context menu + const handleRenameFile = useCallback( + (path: string, newName: string) => { + sendMessage({ type: "rename_file", path, newName }); + }, + [sendMessage] + ); + // Handle "Think about" from FileTree context menu // Appends file path to discussion draft and navigates to Think tab const handleThinkAbout = useCallback( @@ -667,7 +695,7 @@ export function BrowseMode(): React.ReactNode { onRequestSnippets={handleRequestSnippets} /> ) : viewMode === "files" ? ( - + ) : ( )} @@ -830,7 +858,7 @@ export function BrowseMode(): React.ReactNode { onRequestSnippets={handleRequestSnippets} /> ) : viewMode === "files" ? ( - + ) : ( )} diff --git a/frontend/src/components/FileTree.tsx b/frontend/src/components/FileTree.tsx index 126fadf4..44ddcf93 100644 --- a/frontend/src/components/FileTree.tsx +++ b/frontend/src/components/FileTree.tsx @@ -32,6 +32,8 @@ export interface FileTreeProps { onCreateDirectory?: (parentPath: string, name: string) => void; /** Callback when a new file is created */ onCreateFile?: (parentPath: string, name: string) => void; + /** Callback when a file or directory is renamed */ + onRenameFile?: (path: string, newName: string) => void; } /** @@ -389,6 +391,25 @@ function ThinkIcon(): React.ReactNode { ); } +/** + * Rename/Edit icon (pencil). + */ +function RenameIcon(): React.ReactNode { + return ( + + + + ); +} + /** * Context menu state for pin/unpin actions. */ @@ -410,7 +431,7 @@ interface ContextMenuState { * - Touch-friendly with 44px minimum height targets * - Pinned folders for quick access */ -export function FileTree({ onFileSelect, onLoadDirectory, onDeleteFile, onArchiveFile, onThinkAbout, onPinnedAssetsChange, onCreateDirectory, onCreateFile }: FileTreeProps): React.ReactNode { +export function FileTree({ onFileSelect, onLoadDirectory, onDeleteFile, onArchiveFile, onThinkAbout, onPinnedAssetsChange, onCreateDirectory, onCreateFile, onRenameFile }: FileTreeProps): React.ReactNode { const { browser, toggleDirectory, setCurrentPath, pinFolder, unpinFolder } = useSession(); const { currentPath, expandedDirs, directoryCache, isLoading, pinnedFolders } = browser; const [contextMenu, setContextMenu] = useState({ @@ -425,6 +446,7 @@ export function FileTree({ onFileSelect, onLoadDirectory, onDeleteFile, onArchiv const [pendingArchivePath, setPendingArchivePath] = useState(null); const [pendingCreateDirPath, setPendingCreateDirPath] = useState(null); const [pendingCreateFilePath, setPendingCreateFilePath] = useState(null); + const [pendingRenamePath, setPendingRenamePath] = useState(null); // Track which directories are currently loading // For now we just use isLoading for the overall state @@ -625,6 +647,25 @@ export function FileTree({ onFileSelect, onLoadDirectory, onDeleteFile, onArchiv setPendingCreateFilePath(null); }, []); + const handleRenameClick = useCallback(() => { + setPendingRenamePath(contextMenu.path); + closeContextMenu(); + }, [contextMenu.path, closeContextMenu]); + + const handleConfirmRename = useCallback( + (newName: string) => { + if (pendingRenamePath !== null && onRenameFile) { + onRenameFile(pendingRenamePath, newName); + } + setPendingRenamePath(null); + }, + [pendingRenamePath, onRenameFile] + ); + + const handleCancelRename = useCallback(() => { + setPendingRenamePath(null); + }, []); + // Close context menu when clicking outside useEffect(() => { function handleClickOutside(event: MouseEvent) { @@ -804,6 +845,17 @@ export function FileTree({ onFileSelect, onLoadDirectory, onDeleteFile, onArchiv Think about )} + {onRenameFile && ( + + )} {contextMenu.isDirectory && onCreateDirectory && (