diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index 3b4ead1..4c5afec 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -894,6 +894,21 @@ impl Store { Ok(docs) } + /// Lists all directories in a location (excluding the location root). + pub fn dir_list(&self, location_id: LocationId) -> Result, AppError> { + let location = self + .location_get(location_id)? + .ok_or_else(|| AppError::not_found(format!("Location not found: {:?}", location_id)))?; + + let root_path = &location.root_path; + let mut directories = Vec::new(); + self.collect_dirs_recursive(root_path, root_path, &mut directories)?; + directories.sort(); + + log::debug!("Listed {} directories in location {:?}", directories.len(), location_id); + Ok(directories) + } + fn collect_docs_shallow( &self, root: &Path, current: &Path, location_id: LocationId, options: &DocListOptions, docs: &mut Vec, ) -> Result<(), AppError> { @@ -980,6 +995,37 @@ impl Store { Ok(()) } + fn collect_dirs_recursive( + &self, root: &Path, current: &Path, directories: &mut Vec, + ) -> Result<(), AppError> { + let entries = + std::fs::read_dir(current).map_err(|e| AppError::io(format!("Failed to read directory: {}", e)))?; + + for entry in entries { + let entry = entry.map_err(|e| AppError::io(format!("Failed to read entry: {}", e)))?; + let file_type = entry + .file_type() + .map_err(|e| AppError::io(format!("Failed to read entry type: {}", e)))?; + if !file_type.is_dir() { + continue; + } + + let path = entry.path(); + let rel_path = path + .strip_prefix(root) + .map_err(|_| AppError::io("Path not within root"))? + .to_path_buf(); + + if !rel_path.as_os_str().is_empty() { + directories.push(rel_path); + } + + self.collect_dirs_recursive(root, &path, directories)?; + } + + Ok(()) + } + fn read_doc_metadata( &self, path: &Path, location_id: LocationId, rel_path: PathBuf, filename: &str, ) -> Result { @@ -2315,6 +2361,34 @@ mod tests { assert_eq!(deep_hits[0].rel_path, "new/archive/deep/b.md"); } + #[test] + fn test_directory_list_includes_empty_and_nested_directories() { + let (store, _temp) = create_test_store(); + let location_dir = TempDir::new().unwrap(); + let location = store + .location_add("Directory List".to_string(), location_dir.path().to_path_buf()) + .unwrap(); + + store.dir_create(location.id, Path::new("Samples")).unwrap(); + store.dir_create(location.id, Path::new("Samples/sibling")).unwrap(); + store.dir_create(location.id, Path::new("Empty")).unwrap(); + + let directories = store.dir_list(location.id).unwrap(); + let as_strings = directories + .iter() + .map(|path| path.to_string_lossy().to_string()) + .collect::>(); + + assert_eq!( + as_strings, + vec![ + "Empty".to_string(), + "Samples".to_string(), + "Samples/sibling".to_string() + ] + ); + } + #[test] fn test_search_returns_indexed_results() { let (store, _temp) = create_test_store(); diff --git a/package.json b/package.json index 8a35f8e..ec9c5ab 100644 --- a/package.json +++ b/package.json @@ -20,9 +20,6 @@ "www:serve": "pnpm --filter @writer/www serve" }, "dependencies": { - "@atlaskit/pragmatic-drag-and-drop": "^1.7.7", - "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0", - "@atlaskit/pragmatic-drag-and-drop-live-region": "^1.3.3", "@codemirror/autocomplete": "^6.20.0", "@codemirror/commands": "^6.10.2", "@codemirror/lang-markdown": "^6.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0708d2..87fcc1f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,15 +8,6 @@ importers: .: dependencies: - '@atlaskit/pragmatic-drag-and-drop': - specifier: ^1.7.7 - version: 1.7.7 - '@atlaskit/pragmatic-drag-and-drop-hitbox': - specifier: ^1.1.0 - version: 1.1.0 - '@atlaskit/pragmatic-drag-and-drop-live-region': - specifier: ^1.3.3 - version: 1.3.3 '@codemirror/autocomplete': specifier: ^6.20.0 version: 6.20.0 @@ -205,15 +196,6 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} - '@atlaskit/pragmatic-drag-and-drop-hitbox@1.1.0': - resolution: {integrity: sha512-JWt6eVp6Br2FPHRM8s0dUIHQk/jFInGP1f3ti5CdtM1Ji5/pt8Akm44wDC063Gv2i5RGseixtbW0z/t6RYtbdg==} - - '@atlaskit/pragmatic-drag-and-drop-live-region@1.3.3': - resolution: {integrity: sha512-+zk4FsYp+DDZ/M++yTGcjIUdhBhcCRRVrfUkFD06qoVDAsb9853uxPytwoDX7laU4Z9H0eE9A09lROdf6NzAMg==} - - '@atlaskit/pragmatic-drag-and-drop@1.7.7': - resolution: {integrity: sha512-jX+68AoSTqO/fhCyJDTZ38Ey6/wyL2Iq+J/moanma0YyktpnoHxevjY1UNJHYp0NCburdQDZSL1ZFac1mO1osQ==} - '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -1426,9 +1408,6 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} - bind-event-listener@3.0.0: - resolution: {integrity: sha512-PJvH288AWQhKs2v9zyfYdPzlPqf5bXbGMmhmUIY9x4dAUGIWgomO771oBQNwJnMQSnUIXhKu6sgzpBRXTlvb8Q==} - bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} @@ -2369,9 +2348,6 @@ packages: queue@6.0.2: resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==} - raf-schd@4.0.3: - resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==} - range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -2906,21 +2882,6 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} - '@atlaskit/pragmatic-drag-and-drop-hitbox@1.1.0': - dependencies: - '@atlaskit/pragmatic-drag-and-drop': 1.7.7 - '@babel/runtime': 7.28.6 - - '@atlaskit/pragmatic-drag-and-drop-live-region@1.3.3': - dependencies: - '@babel/runtime': 7.28.6 - - '@atlaskit/pragmatic-drag-and-drop@1.7.7': - dependencies: - '@babel/runtime': 7.28.6 - bind-event-listener: 3.0.0 - raf-schd: 4.0.3 - '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -4006,8 +3967,6 @@ snapshots: binary-extensions@2.3.0: {} - bind-event-listener@3.0.0: {} - bindings@1.5.0: dependencies: file-uri-to-path: 1.0.0 @@ -5011,8 +4970,6 @@ snapshots: dependencies: inherits: 2.0.4 - raf-schd@4.0.3: {} - range-parser@1.2.1: {} react-dom@19.2.4(react@19.2.4): diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 1070ad3..1c98915 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -386,6 +386,28 @@ pub fn doc_list( } } +/// Lists directories in a location +#[tauri::command] +pub fn dir_list(state: State<'_, AppState>, location_id: i64) -> CommandResponse> { + let id = LocationId(location_id); + log::debug!("Listing directories for location: id={}", location_id); + + match state.store.dir_list(id) { + Ok(directories) => { + let values = directories + .into_iter() + .map(|path| path.to_string_lossy().to_string()) + .collect::>(); + log::debug!("Found {} directories in location {}", values.len(), location_id); + Ok(CommandResult::ok(values)) + } + Err(e) => { + log::error!("Failed to list directories: {}", e); + Ok(CommandResult::err(e)) + } + } +} + /// Opens a document by location_id and relative path #[tauri::command] pub fn doc_open(state: State<'_, AppState>, location_id: i64, rel_path: String) -> CommandResponse { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index da567d4..a1da8be 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -89,6 +89,7 @@ pub fn run() { cmd::location_remove, cmd::location_validate, cmd::doc_list, + cmd::dir_list, cmd::doc_open, cmd::doc_save, cmd::doc_exists, diff --git a/src/App.css b/src/App.css index 7a445cb..1e7d7fe 100644 --- a/src/App.css +++ b/src/App.css @@ -132,6 +132,7 @@ body, * { scrollbar-width: thin; scrollbar-color: #525252 #161616; + @apply duration-300 transition-all; } *::-webkit-scrollbar { @@ -163,10 +164,58 @@ body, color: #f2f4f8; } +@keyframes sidebar-drop-pulse { + 0% { + box-shadow: 0 0 0 0 rgba(51, 177, 255, 0.45); + } + 70% { + box-shadow: 0 0 0 7px rgba(51, 177, 255, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(51, 177, 255, 0); + } +} + +@keyframes sidebar-drop-edge-pulse { + 0% { + opacity: 0.55; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0.55; + } +} + +.sidebar-drop-pulse { + animation: sidebar-drop-pulse 1.05s ease-in-out infinite; +} + +.sidebar-drop-edge-pulse { + animation: sidebar-drop-edge-pulse 0.8s ease-in-out infinite; +} + +.sidebar-item--unselected:hover { + background-color: var(--color-layer-hover-01); +} + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } + .animate-spin { animation: none; } + .sidebar-drop-pulse, + .sidebar-drop-edge-pulse { + animation: none; + } } @media (prefers-color-scheme: light) { diff --git a/src/__tests__/DocumentItem.test.tsx b/src/__tests__/DocumentItem.test.tsx index d9d6c27..8ddbdc9 100644 --- a/src/__tests__/DocumentItem.test.tsx +++ b/src/__tests__/DocumentItem.test.tsx @@ -1,11 +1,8 @@ import { DocumentItem } from "$components/Sidebar/DocumentItem"; -import { useSidebarState } from "$state/selectors"; import type { DocMeta } from "$types"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("$state/selectors", () => ({ useSidebarState: vi.fn() })); - const createMockDoc = (overrides: Partial = {}): DocMeta => ({ location_id: 1, rel_path: "notes/test.md", @@ -15,25 +12,18 @@ const createMockDoc = (overrides: Partial = {}): DocMeta => ({ ...overrides, }); -const mockSidebarState = { filenameVisibility: false, setDocuments: vi.fn() }; - const createProps = (overrides: Partial[0]> = {}) => ({ doc: createMockDoc(), isSelected: false, - selectedDocPath: undefined, onSelectDocument: vi.fn(), - onRenameDocument: vi.fn().mockResolvedValue(true), - onMoveDocument: vi.fn().mockResolvedValue(true), - onDeleteDocument: vi.fn().mockResolvedValue(true), + onOpenDocumentOperation: vi.fn(), filenameVisibility: false, - id: 1, ...overrides, }); describe("DocumentItem", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(useSidebarState).mockReturnValue(mockSidebarState as unknown as ReturnType); }); describe("display", () => { @@ -53,10 +43,7 @@ describe("DocumentItem", () => { }); it("displays filename when title is empty", () => { - const props = createProps({ - doc: createMockDoc({ title: "", rel_path: "notes/untitled.md" }), - filenameVisibility: false, - }); + const props = createProps({ doc: createMockDoc({ title: "", rel_path: "notes/untitled.md" }) }); render(); expect(screen.getByText("untitled.md")).toBeInTheDocument(); }); @@ -67,9 +54,7 @@ describe("DocumentItem", () => { const props = createProps(); render(); - const item = screen.getByText("Test Document"); - fireEvent.contextMenu(item); - + fireEvent.contextMenu(screen.getByText("Test Document")); expect(screen.getByRole("menu")).toBeInTheDocument(); }); @@ -77,8 +62,7 @@ describe("DocumentItem", () => { const props = createProps(); render(); - const item = screen.getByText("Test Document"); - fireEvent.contextMenu(item); + fireEvent.contextMenu(screen.getByText("Test Document")); expect(screen.getByRole("menuitem", { name: "Rename" })).toBeInTheDocument(); expect(screen.getByRole("menuitem", { name: "Move" })).toBeInTheDocument(); @@ -90,134 +74,46 @@ describe("DocumentItem", () => { const props = createProps({ onSelectDocument }); render(); - const item = screen.getByText("Test Document"); - fireEvent.contextMenu(item); + fireEvent.contextMenu(screen.getByText("Test Document"), { clientX: 120, clientY: 180 }); fireEvent.click(screen.getByRole("menuitem", { name: "Open" })); expect(onSelectDocument).toHaveBeenCalledWith(1, "notes/test.md"); }); - }); - - describe("rename dialog", () => { - it("opens rename dialog when Rename is clicked", () => { - const props = createProps(); - render(); - - const item = screen.getByText("Test Document"); - fireEvent.contextMenu(item); - fireEvent.click(screen.getByRole("menuitem", { name: "Rename" })); - - expect(screen.getByText("Rename Document")).toBeInTheDocument(); - expect(screen.getByDisplayValue("test.md")).toBeInTheDocument(); - }); - - it("pins rename dialog near the operation trigger location", async () => { - const props = createProps(); - render(); - - const item = screen.getByText("Test Document"); - fireEvent.contextMenu(item, { clientX: 120, clientY: 180 }); - fireEvent.click(screen.getByRole("menuitem", { name: "Rename" })); - - await waitFor(() => { - const dialog = screen.getByRole("dialog", { name: "Rename document" }); - expect(dialog).toHaveStyle({ left: "132px", top: "192px" }); - }); - }); - - it("closes rename dialog on outside click", async () => { - const props = createProps(); - render(); - - const item = screen.getByText("Test Document"); - fireEvent.contextMenu(item); - fireEvent.click(screen.getByRole("menuitem", { name: "Rename" })); - - expect(screen.getByText("Rename Document")).toBeInTheDocument(); - fireEvent.pointerDown(document.body); - - await waitFor(() => { - expect(screen.queryByText("Rename Document")).not.toBeInTheDocument(); - }); - }); - it("calls onRenameDocument with new name", async () => { - const onRenameDocument = vi.fn().mockResolvedValue(true); - const props = createProps({ onRenameDocument }); + it("opens rename operation with anchor position", () => { + const onOpenDocumentOperation = vi.fn(); + const doc = createMockDoc(); + const props = createProps({ doc, onOpenDocumentOperation }); render(); - const item = screen.getByText("Test Document"); - fireEvent.contextMenu(item); + fireEvent.contextMenu(screen.getByText("Test Document"), { clientX: 120, clientY: 180 }); fireEvent.click(screen.getByRole("menuitem", { name: "Rename" })); - const input = screen.getByDisplayValue("test.md"); - fireEvent.change(input, { target: { value: "renamed.md" } }); - fireEvent.click(screen.getByRole("button", { name: "Rename" })); - - await waitFor(() => { - expect(onRenameDocument).toHaveBeenCalledWith(1, "notes/test.md", "renamed.md"); - }); - }); - }); - - describe("move dialog", () => { - it("opens move dialog when Move is clicked", () => { - const props = createProps(); - render(); - - const item = screen.getByText("Test Document"); - fireEvent.contextMenu(item); - fireEvent.click(screen.getByRole("menuitem", { name: "Move" })); - - expect(screen.getByText("Move Document")).toBeInTheDocument(); - expect(screen.getByDisplayValue("notes/test.md")).toBeInTheDocument(); + expect(onOpenDocumentOperation).toHaveBeenCalledWith("rename", doc, { x: 120, y: 180 }); }); - it("calls onMoveDocument with new path", async () => { - const onMoveDocument = vi.fn().mockResolvedValue(true); - const props = createProps({ onMoveDocument }); + it("opens move operation", () => { + const onOpenDocumentOperation = vi.fn(); + const doc = createMockDoc(); + const props = createProps({ doc, onOpenDocumentOperation }); render(); - const item = screen.getByText("Test Document"); - fireEvent.contextMenu(item); + fireEvent.contextMenu(screen.getByText("Test Document"), { clientX: 40, clientY: 50 }); fireEvent.click(screen.getByRole("menuitem", { name: "Move" })); - const input = screen.getByDisplayValue("notes/test.md"); - fireEvent.change(input, { target: { value: "archive/test.md" } }); - fireEvent.click(screen.getByRole("button", { name: "Move" })); - - await waitFor(() => { - expect(onMoveDocument).toHaveBeenCalledWith(1, "notes/test.md", "archive/test.md"); - }); + expect(onOpenDocumentOperation).toHaveBeenCalledWith("move", doc, { x: 40, y: 50 }); }); - }); - describe("delete dialog", () => { - it("opens delete confirmation dialog when Delete is clicked", () => { - const props = createProps(); + it("opens delete operation", () => { + const onOpenDocumentOperation = vi.fn(); + const doc = createMockDoc(); + const props = createProps({ doc, onOpenDocumentOperation }); render(); - const item = screen.getByText("Test Document"); - fireEvent.contextMenu(item); + fireEvent.contextMenu(screen.getByText("Test Document"), { clientX: 12, clientY: 34 }); fireEvent.click(screen.getByRole("menuitem", { name: "Delete" })); - expect(screen.getByText("Delete Document")).toBeInTheDocument(); - }); - - it("calls onDeleteDocument when confirmed", async () => { - const onDeleteDocument = vi.fn().mockResolvedValue(true); - const props = createProps({ onDeleteDocument }); - render(); - - const item = screen.getByText("Test Document"); - fireEvent.contextMenu(item); - fireEvent.click(screen.getByRole("menuitem", { name: "Delete" })); - - fireEvent.click(screen.getByRole("button", { name: "Delete" })); - - await waitFor(() => { - expect(onDeleteDocument).toHaveBeenCalledWith(1, "notes/test.md"); - }); + expect(onOpenDocumentOperation).toHaveBeenCalledWith("delete", doc, { x: 12, y: 34 }); }); }); }); diff --git a/src/__tests__/DocumentOperationDialog.test.tsx b/src/__tests__/DocumentOperationDialog.test.tsx new file mode 100644 index 0000000..a0dbedd --- /dev/null +++ b/src/__tests__/DocumentOperationDialog.test.tsx @@ -0,0 +1,89 @@ +import { + DocumentOperationDialog, + type DocumentOperationRequest, +} from "$components/Sidebar/DocumentOperationDialog"; +import type { DocMeta } from "$types"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +const DOC: DocMeta = { + location_id: 1, + rel_path: "notes/test.md", + title: "Test Document", + updated_at: "2026-01-01T00:00:00Z", + word_count: 12, +}; + +function renderDialog(operation: DocumentOperationRequest) { + const onClose = vi.fn(); + const onRenameDocument = vi.fn().mockResolvedValue(true); + const onMoveDocument = vi.fn().mockResolvedValue(true); + const onDeleteDocument = vi.fn().mockResolvedValue(true); + const onRefreshSidebar = vi.fn(); + + render( + , + ); + + return { onClose, onRenameDocument, onMoveDocument, onDeleteDocument, onRefreshSidebar }; +} + +describe("DocumentOperationDialog", () => { + it("renames a document", async () => { + const { onRenameDocument, onRefreshSidebar, onClose } = renderDialog({ + type: "rename", + doc: DOC, + anchor: { x: 120, y: 180 }, + }); + + expect(screen.getByDisplayValue("test.md")).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText("New name"), { target: { value: "renamed.md" } }); + fireEvent.click(screen.getByRole("button", { name: "Rename" })); + + await waitFor(() => { + expect(onRenameDocument).toHaveBeenCalledWith(1, "notes/test.md", "renamed.md"); + expect(onRefreshSidebar).toHaveBeenCalledWith(1); + expect(onClose).toHaveBeenCalled(); + }); + }); + + it("moves a document", async () => { + const { onMoveDocument, onRefreshSidebar, onClose } = renderDialog({ + type: "move", + doc: DOC, + anchor: { x: 10, y: 20 }, + }); + + expect(screen.getByDisplayValue("notes/test.md")).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText("New path"), { target: { value: "archive/test.md" } }); + fireEvent.click(screen.getByRole("button", { name: "Move" })); + + await waitFor(() => { + expect(onMoveDocument).toHaveBeenCalledWith(1, "notes/test.md", "archive/test.md"); + expect(onRefreshSidebar).toHaveBeenCalledWith(1); + expect(onClose).toHaveBeenCalled(); + }); + }); + + it("deletes a document", async () => { + const { onDeleteDocument, onRefreshSidebar, onClose } = renderDialog({ + type: "delete", + doc: DOC, + anchor: { x: 15, y: 25 }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Delete" })); + + await waitFor(() => { + expect(onDeleteDocument).toHaveBeenCalledWith(1, "notes/test.md"); + expect(onRefreshSidebar).toHaveBeenCalledWith(1); + expect(onClose).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/__tests__/PdfPreview.test.tsx b/src/__tests__/PdfPreview.test.tsx index f4ca533..a8ed12e 100644 --- a/src/__tests__/PdfPreview.test.tsx +++ b/src/__tests__/PdfPreview.test.tsx @@ -11,7 +11,7 @@ const toBlobMock = vi.fn(); const mockPdfDoc = { numPages: 3, getPage: vi.fn(), destroy: vi.fn() }; const mockGetDocument = vi.fn(); -vi.mock("$components/pdf/MarkdownPdfDocument", () => ({ MarkdownPdfDocument: () => null })); +vi.mock("$components/export/MarkdownPdfDocument", () => ({ MarkdownPdfDocument: () => null })); vi.mock("$pdf/fonts", () => ({ ensurePdfFontRegistered: vi.fn() })); diff --git a/src/__tests__/Sidebar.test.tsx b/src/__tests__/Sidebar.test.tsx index a7113bd..1618c0c 100644 --- a/src/__tests__/Sidebar.test.tsx +++ b/src/__tests__/Sidebar.test.tsx @@ -1,33 +1,30 @@ import { Sidebar } from "$components/Sidebar"; -import { useWorkspaceController } from "$hooks/controllers/useWorkspaceController"; +import { useSidebarActions } from "$hooks/controllers/useSidebarActions"; import { useSidebarState } from "$state/selectors"; +import { showErrorToast, showSuccessToast, showWarnToast } from "$state/stores/toasts"; import { act, fireEvent, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("$state/selectors", () => ({ useSidebarState: vi.fn() })); -vi.mock("$hooks/controllers/useWorkspaceController", () => ({ useWorkspaceController: vi.fn() })); +vi.mock("$hooks/controllers/useSidebarActions", () => ({ useSidebarActions: vi.fn() })); +vi.mock("$state/stores/toasts", () => ({ showErrorToast: vi.fn(), showSuccessToast: vi.fn(), showWarnToast: vi.fn() })); const { mockMonitorForElements, mockExtractClosestEdge, mockAnnounce } = vi.hoisted(() => ({ mockMonitorForElements: vi.fn(), mockExtractClosestEdge: vi.fn<() => "top" | "bottom" | null>(() => null), mockAnnounce: vi.fn(), })); -vi.mock("@atlaskit/pragmatic-drag-and-drop/element/adapter", async () => { - const actual = await vi.importActual( - "@atlaskit/pragmatic-drag-and-drop/element/adapter", - ); - return { ...actual, monitorForElements: mockMonitorForElements }; +vi.mock("$dnd", async () => { + const actual = await vi.importActual("$dnd"); + return { + ...actual, + monitorForElements: mockMonitorForElements, + extractClosestEdge: mockExtractClosestEdge, + announce: mockAnnounce, + cleanup: vi.fn(), + }; }); -vi.mock("@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge", async () => { - const actual = await vi.importActual( - "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge", - ); - return { ...actual, extractClosestEdge: mockExtractClosestEdge }; -}); - -vi.mock("@atlaskit/pragmatic-drag-and-drop-live-region", () => ({ announce: mockAnnounce, cleanup: vi.fn() })); - const createSidebarState = (overrides: Partial> = {}) => ({ locations: [{ id: 1, name: "Notes", root_path: "/tmp/notes", added_at: "2026-01-01T00:00:00Z" }, { id: 2, @@ -38,6 +35,7 @@ const createSidebarState = (overrides: Partial> = {}, -): ReturnType => ({ - locations: [], - documents: [], - selectedLocationId: undefined, - selectedDocPath: undefined, - locationDocuments: [], - sidebarFilter: "", - isSidebarLoading: false, - isSessionHydrated: true, - refreshingLocationId: undefined, - sidebarRefreshReason: null, - tabs: [], - activeTabId: null, - activeTab: null, - setSidebarFilter: vi.fn(), - markActiveTabModified: vi.fn(), +const createSidebarActionsState = ( + overrides: Partial> = {}, +): ReturnType => ({ handleAddLocation: vi.fn(), handleRemoveLocation: vi.fn(), - handleSelectLocation: vi.fn(), handleSelectDocument: vi.fn(), - handleSelectTab: vi.fn(), - handleCloseTab: vi.fn(), - handleReorderTabs: vi.fn(), - handleCreateDraftTab: vi.fn(), handleCreateNewDocument: vi.fn(), handleRefreshSidebar: vi.fn(), handleRenameDocument: vi.fn(), handleMoveDocument: vi.fn(), + handleMoveDirectory: vi.fn(), handleDeleteDocument: vi.fn(), - handleCreateDirectory: vi.fn(), handleImportExternalFile: vi.fn(), ...overrides, }); describe("Sidebar", () => { - let monitorArgs: { onDrop?: (args: unknown) => void; onDragStart?: (args: unknown) => void } | undefined; + let monitorArgs: { + onDrop?: (args: unknown) => void; + onDragStart?: (args: unknown) => void; + onDropTargetChange?: (args: unknown) => void; + } | undefined; beforeEach(() => { vi.clearAllMocks(); @@ -99,7 +82,7 @@ describe("Sidebar", () => { return () => {}; }); mockExtractClosestEdge.mockReturnValue(null); - vi.mocked(useWorkspaceController).mockReturnValue(createWorkspaceControllerState()); + vi.mocked(useSidebarActions).mockReturnValue(createSidebarActionsState()); }); it("shows a single new document action and creates a doc for the selected location", () => { @@ -131,7 +114,7 @@ describe("Sidebar", () => { it("refreshes sidebar documents for selected location", () => { vi.mocked(useSidebarState).mockReturnValue(createSidebarState()); const handleRefreshSidebar = vi.fn(); - vi.mocked(useWorkspaceController).mockReturnValue(createWorkspaceControllerState({ handleRefreshSidebar })); + vi.mocked(useSidebarActions).mockReturnValue(createSidebarActionsState({ handleRefreshSidebar })); render(); @@ -174,14 +157,34 @@ describe("Sidebar", () => { expect(screen.getByText("Quick capture note")).toBeInTheDocument(); }); + it("renders empty directories from backend directory listing", () => { + vi.mocked(useSidebarState).mockReturnValue( + createSidebarState({ + directories: ["Samples", "Samples/sibling", "Archive"], + documents: [{ + location_id: 1, + rel_path: "Samples/some-file.md", + title: "Some File", + updated_at: "2026-02-27T10:15:00Z", + word_count: 12, + }], + }), + ); + + render(); + + expect(screen.getAllByText("Archive").length).toBeGreaterThan(0); + expect(screen.getAllByText("Samples").length).toBeGreaterThan(0); + }); + it("moves a document to a different location on drop", () => { const handleMoveDocument = vi.fn().mockResolvedValue(true); - vi.mocked(useWorkspaceController).mockReturnValue(createWorkspaceControllerState({ handleMoveDocument })); + vi.mocked(useSidebarActions).mockReturnValue(createSidebarActionsState({ handleMoveDocument })); vi.mocked(useSidebarState).mockReturnValue( createSidebarState({ documents: [{ location_id: 1, - rel_path: "notes/test.md", + rel_path: "test.md", title: "Test", updated_at: "2026-01-01T00:00:00Z", word_count: 1, @@ -203,6 +206,708 @@ describe("Sidebar", () => { expect(handleMoveDocument).toHaveBeenCalledWith(1, "notes/test.md", "test.md", 2); }); + it("shows a success toast when a dropped document move succeeds", async () => { + const handleMoveDocument = vi.fn().mockResolvedValue(true); + vi.mocked(useSidebarActions).mockReturnValue(createSidebarActionsState({ handleMoveDocument })); + vi.mocked(useSidebarState).mockReturnValue( + createSidebarState({ + documents: [{ + location_id: 1, + rel_path: "notes/test.md", + title: "Test", + updated_at: "2026-01-01T00:00:00Z", + word_count: 1, + }], + }), + ); + + render(); + + await act(async () => { + monitorArgs?.onDrop?.({ + source: { data: { type: "document", locationId: 1, relPath: "notes/test.md", title: "Test" } }, + location: { + current: { dropTargets: [{ data: { locationId: 2, targetType: "location" } }], input: { altKey: false } }, + }, + }); + await Promise.resolve(); + }); + + expect(showSuccessToast).toHaveBeenCalledWith("Moved Test to Archive"); + expect(showErrorToast).not.toHaveBeenCalled(); + }); + + it("shows an error toast when a dropped document move fails", async () => { + const handleMoveDocument = vi.fn().mockResolvedValue(false); + vi.mocked(useSidebarActions).mockReturnValue(createSidebarActionsState({ handleMoveDocument })); + vi.mocked(useSidebarState).mockReturnValue( + createSidebarState({ + documents: [{ + location_id: 1, + rel_path: "notes/test.md", + title: "Test", + updated_at: "2026-01-01T00:00:00Z", + word_count: 1, + }], + }), + ); + + render(); + + await act(async () => { + monitorArgs?.onDrop?.({ + source: { data: { type: "document", locationId: 1, relPath: "notes/test.md", title: "Test" } }, + location: { + current: { dropTargets: [{ data: { locationId: 2, targetType: "location" } }], input: { altKey: false } }, + }, + }); + await Promise.resolve(); + }); + + expect(showErrorToast).toHaveBeenCalledWith("Could not move Test"); + }); + + it("shows a warning toast when dropped on a non-actionable target", () => { + vi.mocked(useSidebarState).mockReturnValue( + createSidebarState({ + documents: [{ + location_id: 1, + rel_path: "notes/test.md", + title: "Test", + updated_at: "2026-01-01T00:00:00Z", + word_count: 1, + }], + }), + ); + + render(); + + act(() => { + monitorArgs?.onDrop?.({ + source: { data: { type: "document", locationId: 1, relPath: "test.md", title: "Test" } }, + location: { + current: { dropTargets: [{ data: { locationId: 1, targetType: "location" } }], input: { altKey: false } }, + }, + }); + }); + + expect(showWarnToast).toHaveBeenCalledWith("Drop target is not valid for moving this file"); + }); + + it("moves into folder target when it is the active innermost destination", () => { + const handleMoveDocument = vi.fn().mockResolvedValue(true); + vi.mocked(useSidebarActions).mockReturnValue(createSidebarActionsState({ handleMoveDocument })); + vi.mocked(useSidebarState).mockReturnValue( + createSidebarState({ + documents: [{ + location_id: 1, + rel_path: "notes/test.md", + title: "Test", + updated_at: "2026-01-01T00:00:00Z", + word_count: 1, + }], + }), + ); + + render(); + + act(() => { + monitorArgs?.onDrop?.({ + source: { data: { type: "document", locationId: 1, relPath: "notes/test.md", title: "Test" } }, + location: { + current: { + dropTargets: [{ data: { locationId: 2, folderPath: "archive/2026", targetType: "folder" } }, { + data: { locationId: 2, targetType: "location" }, + }], + input: { altKey: false }, + }, + }, + }); + }); + + expect(handleMoveDocument).toHaveBeenCalledWith(1, "notes/test.md", "archive/2026/test.md", 2); + }); + + it("moves into folder even when location target appears before folder target", () => { + const handleMoveDocument = vi.fn().mockResolvedValue(true); + vi.mocked(useSidebarActions).mockReturnValue(createSidebarActionsState({ handleMoveDocument })); + vi.mocked(useSidebarState).mockReturnValue( + createSidebarState({ + documents: [{ + location_id: 1, + rel_path: "samples/some-file.md", + title: "Some File", + updated_at: "2026-01-01T00:00:00Z", + word_count: 1, + }], + }), + ); + + render(); + + act(() => { + monitorArgs?.onDrop?.({ + source: { data: { type: "document", locationId: 1, relPath: "samples/some-file.md", title: "Some File" } }, + location: { + current: { + dropTargets: [{ data: { locationId: 1, targetType: "location" } }, { + data: { locationId: 1, folderPath: "samples/sibling", targetType: "folder" }, + }], + input: { altKey: false }, + }, + }, + }); + }); + + expect(handleMoveDocument).toHaveBeenCalledWith(1, "samples/some-file.md", "samples/sibling/some-file.md", 1); + }); + + it("falls back to pointer-hovered folder target when drop targets only include location", () => { + const handleMoveDocument = vi.fn().mockResolvedValue(true); + vi.mocked(useSidebarActions).mockReturnValue(createSidebarActionsState({ handleMoveDocument })); + vi.mocked(useSidebarState).mockReturnValue( + createSidebarState({ + directories: ["samples", "samples/sibling"], + documents: [{ + location_id: 1, + rel_path: "samples/some-file.md", + title: "Some File", + updated_at: "2026-01-01T00:00:00Z", + word_count: 1, + }], + }), + ); + + const folderHit = document.createElement("div"); + folderHit.dataset.locationId = "1"; + folderHit.dataset.folderPath = "samples/sibling"; + globalThis.document.elementFromPoint = vi.fn(() => folderHit); + + render(); + + act(() => { + monitorArgs?.onDrop?.({ + source: { data: { type: "document", locationId: 1, relPath: "samples/some-file.md", title: "Some File" } }, + location: { + current: { + dropTargets: [{ data: { locationId: 1, targetType: "location" } }], + input: { altKey: false, clientX: 18, clientY: 22 }, + }, + }, + }); + }); + + expect(handleMoveDocument).toHaveBeenCalledWith(1, "samples/some-file.md", "samples/sibling/some-file.md", 1); + }); + + it("defaults to location root when elementFromPoint cannot resolve folder", () => { + const handleMoveDocument = vi.fn().mockResolvedValue(true); + vi.mocked(useSidebarActions).mockReturnValue(createSidebarActionsState({ handleMoveDocument })); + vi.mocked(useSidebarState).mockReturnValue( + createSidebarState({ + directories: ["samples", "samples/sibling"], + documents: [{ + location_id: 1, + rel_path: "samples/some-file.md", + title: "Some File", + updated_at: "2026-01-01T00:00:00Z", + word_count: 1, + }], + }), + ); + globalThis.document.elementFromPoint = vi.fn(() => null); + + render(); + + const folderRow = document.createElement("div"); + folderRow.dataset.dropFolderRow = "true"; + folderRow.dataset.locationId = "1"; + folderRow.dataset.folderPath = "samples/sibling"; + folderRow.dataset.folderDepth = "2"; + folderRow.getBoundingClientRect = vi.fn(() => + ({ + left: 0, + right: 300, + top: 500, + bottom: 560, + width: 300, + height: 60, + x: 0, + y: 500, + toJSON: () => ({}), + }) as DOMRect + ); + const originalQuerySelectorAll = document.querySelectorAll.bind(document); + vi.spyOn(document, "querySelectorAll").mockImplementation((selectors) => { + if ( + selectors + === "[data-drop-folder-row][data-location-id], [data-drop-document-row][data-location-id], [data-drop-location-root][data-location-id]" + ) { + return [folderRow] as unknown as NodeListOf; + } + return originalQuerySelectorAll(selectors); + }); + + act(() => { + monitorArgs?.onDrop?.({ + source: { data: { type: "document", locationId: 1, relPath: "samples/some-file.md", title: "Some File" } }, + location: { + current: { + dropTargets: [{ data: { locationId: 1, targetType: "location" } }], + input: { altKey: false, clientX: 120, clientY: 530 }, + }, + }, + }); + }); + + expect(handleMoveDocument).toHaveBeenCalledWith(1, "samples/some-file.md", "some-file.md", 1); + }); + + it("defaults to location root when pointer is slightly outside a folder row", () => { + const handleMoveDocument = vi.fn().mockResolvedValue(true); + vi.mocked(useSidebarActions).mockReturnValue(createSidebarActionsState({ handleMoveDocument })); + vi.mocked(useSidebarState).mockReturnValue( + createSidebarState({ + directories: ["samples", "samples/sibling"], + documents: [{ + location_id: 1, + rel_path: "samples/some-file.md", + title: "Some File", + updated_at: "2026-01-01T00:00:00Z", + word_count: 1, + }], + }), + ); + globalThis.document.elementFromPoint = vi.fn(() => null); + + render(); + + const folderRow = document.createElement("div"); + folderRow.dataset.dropFolderRow = "true"; + folderRow.dataset.locationId = "1"; + folderRow.dataset.folderPath = "samples/sibling"; + folderRow.dataset.folderDepth = "2"; + folderRow.getBoundingClientRect = vi.fn(() => + ({ + left: 0, + right: 140, + top: 500, + bottom: 560, + width: 140, + height: 60, + x: 0, + y: 500, + toJSON: () => ({}), + }) as DOMRect + ); + const originalQuerySelectorAll = document.querySelectorAll.bind(document); + vi.spyOn(document, "querySelectorAll").mockImplementation((selectors) => { + if ( + selectors + === "[data-drop-folder-row][data-location-id], [data-drop-document-row][data-location-id], [data-drop-location-root][data-location-id]" + ) { + return [folderRow] as unknown as NodeListOf; + } + return originalQuerySelectorAll(selectors); + }); + + act(() => { + monitorArgs?.onDrop?.({ + source: { data: { type: "document", locationId: 1, relPath: "samples/some-file.md", title: "Some File" } }, + location: { + current: { + dropTargets: [{ data: { locationId: 1, targetType: "location" } }], + input: { altKey: false, clientX: 175, clientY: 530 }, + }, + }, + }); + }); + + expect(handleMoveDocument).toHaveBeenCalledWith(1, "samples/some-file.md", "some-file.md", 1); + }); + + it("defaults to location root when folder body zones are not directly resolved by pointer hit testing", () => { + const handleMoveDocument = vi.fn().mockResolvedValue(true); + vi.mocked(useSidebarActions).mockReturnValue(createSidebarActionsState({ handleMoveDocument })); + vi.mocked(useSidebarState).mockReturnValue( + createSidebarState({ + directories: ["samples", "samples/sibling"], + documents: [{ + location_id: 1, + rel_path: "samples/some-file.md", + title: "Some File", + updated_at: "2026-01-01T00:00:00Z", + word_count: 1, + }], + }), + ); + globalThis.document.elementFromPoint = vi.fn(() => null); + + render(); + + const folderZone = document.createElement("div"); + folderZone.dataset.dropFolderZone = "true"; + folderZone.dataset.locationId = "1"; + folderZone.dataset.folderPath = "samples/sibling"; + folderZone.dataset.folderDepth = "2"; + folderZone.getBoundingClientRect = vi.fn(() => + ({ + left: 0, + right: 300, + top: 500, + bottom: 700, + width: 300, + height: 200, + x: 0, + y: 500, + toJSON: () => ({}), + }) as DOMRect + ); + + const originalQuerySelectorAll = document.querySelectorAll.bind(document); + vi.spyOn(document, "querySelectorAll").mockImplementation((selectors) => { + if ( + selectors + === "[data-drop-folder-row][data-location-id], [data-drop-document-row][data-location-id], [data-drop-location-root][data-location-id]" + ) { + return [folderZone] as unknown as NodeListOf; + } + return originalQuerySelectorAll(selectors); + }); + + act(() => { + monitorArgs?.onDrop?.({ + source: { data: { type: "document", locationId: 1, relPath: "samples/some-file.md", title: "Some File" } }, + location: { + current: { + dropTargets: [{ data: { locationId: 1, targetType: "location" } }], + input: { altKey: false, clientX: 175, clientY: 640 }, + }, + }, + }); + }); + + expect(handleMoveDocument).toHaveBeenCalledWith(1, "samples/some-file.md", "some-file.md", 1); + }); + + it("does not move root documents when drop metadata only reports location", () => { + const handleMoveDocument = vi.fn().mockResolvedValue(true); + vi.mocked(useSidebarActions).mockReturnValue(createSidebarActionsState({ handleMoveDocument })); + vi.mocked(useSidebarState).mockReturnValue( + createSidebarState({ + directories: ["samples", "samples/sibling"], + documents: [{ + location_id: 1, + rel_path: "draft.md", + title: "Draft", + updated_at: "2026-01-01T00:00:00Z", + word_count: 1, + }], + }), + ); + globalThis.document.elementFromPoint = vi.fn(() => null); + + render(); + + const parentRow = document.createElement("div"); + parentRow.dataset.dropFolderRow = "true"; + parentRow.dataset.locationId = "1"; + parentRow.dataset.folderPath = "samples"; + parentRow.dataset.folderDepth = "1"; + parentRow.getBoundingClientRect = vi.fn(() => + ({ + left: 0, + right: 300, + top: 500, + bottom: 560, + width: 300, + height: 60, + x: 0, + y: 500, + toJSON: () => ({}), + }) as DOMRect + ); + + const childRow = document.createElement("div"); + childRow.dataset.dropFolderRow = "true"; + childRow.dataset.locationId = "1"; + childRow.dataset.folderPath = "samples/sibling"; + childRow.dataset.folderDepth = "2"; + childRow.getBoundingClientRect = vi.fn(() => + ({ + left: 0, + right: 300, + top: 500, + bottom: 560, + width: 300, + height: 60, + x: 0, + y: 500, + toJSON: () => ({}), + }) as DOMRect + ); + + const originalQuerySelectorAll = document.querySelectorAll.bind(document); + vi.spyOn(document, "querySelectorAll").mockImplementation((selectors) => { + if ( + selectors + === "[data-drop-folder-row][data-location-id], [data-drop-document-row][data-location-id], [data-drop-location-root][data-location-id]" + ) { + return [parentRow, childRow] as unknown as NodeListOf; + } + return originalQuerySelectorAll(selectors); + }); + + act(() => { + monitorArgs?.onDrop?.({ + source: { data: { type: "document", locationId: 1, relPath: "draft.md", title: "Draft" } }, + location: { + current: { + dropTargets: [{ data: { locationId: 1, targetType: "location" } }], + input: { altKey: false, clientX: 175, clientY: 530 }, + }, + }, + }); + }); + + expect(handleMoveDocument).not.toHaveBeenCalled(); + expect(showWarnToast).toHaveBeenCalledWith("Drop target is not valid for moving this file"); + }); + + it("uses current metadata when drop metadata degrades to location-only", () => { + const handleMoveDocument = vi.fn().mockResolvedValue(true); + vi.mocked(useSidebarActions).mockReturnValue(createSidebarActionsState({ handleMoveDocument })); + vi.mocked(useSidebarState).mockReturnValue( + createSidebarState({ + directories: ["Samples", "sibling-dir"], + documents: [{ + location_id: 1, + rel_path: "Samples/some-file.md", + title: "Some File", + updated_at: "2026-01-01T00:00:00Z", + word_count: 1, + }], + }), + ); + + render(); + + act(() => { + monitorArgs?.onDropTargetChange?.({ + source: { data: { type: "document", locationId: 1, relPath: "Samples/some-file.md", title: "Some File" } }, + location: { + current: { + dropTargets: [{ data: { locationId: 1, folderPath: "sibling-dir", targetType: "folder" } }], + input: { altKey: false }, + }, + }, + }); + }); + + act(() => { + monitorArgs?.onDrop?.({ + source: { data: { type: "document", locationId: 1, relPath: "Samples/some-file.md", title: "Some File" } }, + location: { + current: { dropTargets: [{ data: { locationId: 1, targetType: "location" } }], input: { altKey: false } }, + previous: { dropTargets: [{ data: { locationId: 1, folderPath: "sibling-dir", targetType: "folder" } }] }, + }, + }); + }); + + expect(handleMoveDocument).toHaveBeenCalledWith(1, "Samples/some-file.md", "some-file.md", 1); + }); + + it("announces location hover when monitor reports location-only after a folder target", () => { + vi.mocked(useSidebarState).mockReturnValue( + createSidebarState({ + directories: ["Samples", "sibling-dir"], + documents: [{ + location_id: 1, + rel_path: "Samples/some-file.md", + title: "Some File", + updated_at: "2026-01-01T00:00:00Z", + word_count: 1, + }], + }), + ); + + render(); + + act(() => { + monitorArgs?.onDropTargetChange?.({ + source: { data: { type: "document", locationId: 1, relPath: "Samples/some-file.md", title: "Some File" } }, + location: { + current: { + dropTargets: [{ data: { locationId: 1, folderPath: "sibling-dir", targetType: "folder" } }], + input: { altKey: false }, + }, + }, + }); + }); + + act(() => { + monitorArgs?.onDropTargetChange?.({ + source: { data: { type: "document", locationId: 1, relPath: "Samples/some-file.md", title: "Some File" } }, + location: { + current: { dropTargets: [{ data: { locationId: 1, targetType: "location" } }], input: { altKey: false } }, + previous: { dropTargets: [{ data: { locationId: 1, folderPath: "sibling-dir", targetType: "folder" } }] }, + }, + }); + }); + + expect(mockAnnounce).toHaveBeenLastCalledWith("Over Notes"); + }); + + it("moves a folder into another folder in the same location on drop", () => { + const handleMoveDirectory = vi.fn().mockResolvedValue(true); + vi.mocked(useSidebarActions).mockReturnValue(createSidebarActionsState({ handleMoveDirectory })); + vi.mocked(useSidebarState).mockReturnValue( + createSidebarState({ + directories: ["Samples", "Archive"], + documents: [{ + location_id: 1, + rel_path: "Samples/some-file.md", + title: "Some File", + updated_at: "2026-01-01T00:00:00Z", + word_count: 1, + }], + }), + ); + + render(); + + act(() => { + monitorArgs?.onDrop?.({ + source: { data: { type: "folder", locationId: 1, relPath: "Samples", title: "Samples" } }, + location: { + current: { + dropTargets: [{ data: { locationId: 1, folderPath: "Archive", targetType: "folder" } }], + input: { altKey: false }, + }, + }, + }); + }); + + expect(handleMoveDirectory).toHaveBeenCalledWith(1, "Samples", "Archive/Samples"); + }); + + it("announces folder target when folder is the active destination", () => { + vi.mocked(useSidebarState).mockReturnValue( + createSidebarState({ + documents: [{ + location_id: 1, + rel_path: "archive/2026/file.md", + title: "File", + updated_at: "2026-01-01T00:00:00Z", + word_count: 1, + }], + }), + ); + render(); + fireEvent.click(screen.getByText("archive")); + + act(() => { + monitorArgs?.onDropTargetChange?.({ + source: { data: { type: "document", locationId: 1, relPath: "notes/test.md", title: "Test" } }, + location: { + current: { + dropTargets: [{ data: { locationId: 1, folderPath: "archive/2026", targetType: "folder" } }, { + data: { locationId: 1, targetType: "location" }, + }], + }, + }, + }); + }); + + expect(mockAnnounce).toHaveBeenCalledWith("Over archive/2026 in Notes"); + expect(screen.getByText("2026").closest(".sidebar-item")).toHaveClass("ring-border-interactive"); + }); + + it("moves document to location root when dropped on a root-level neighbor", () => { + const handleMoveDocument = vi.fn().mockResolvedValue(true); + vi.mocked(useSidebarActions).mockReturnValue(createSidebarActionsState({ handleMoveDocument })); + vi.mocked(useSidebarState).mockReturnValue( + createSidebarState({ + documents: [{ + location_id: 1, + rel_path: "archive/note.md", + title: "Note", + updated_at: "2026-01-01T00:00:00Z", + word_count: 1, + }, { location_id: 1, rel_path: "todo.md", title: "Todo", updated_at: "2026-01-01T00:00:00Z", word_count: 1 }], + }), + ); + + render(); + + act(() => { + monitorArgs?.onDrop?.({ + source: { data: { type: "document", locationId: 1, relPath: "archive/note.md", title: "Note" } }, + location: { + current: { + dropTargets: [{ data: { locationId: 1, relPath: "todo.md", targetType: "document" } }], + input: { altKey: false }, + }, + }, + }); + }); + + expect(handleMoveDocument).toHaveBeenCalledWith(1, "archive/note.md", "note.md", 1); + }); + + it("keeps document reordering when a folder ancestor is also active", () => { + const setDocuments = vi.fn(); + mockExtractClosestEdge.mockReturnValue("bottom"); + vi.mocked(useSidebarState).mockReturnValue( + createSidebarState({ + setDocuments, + documents: [{ + location_id: 1, + rel_path: "archive/one.md", + title: "One", + updated_at: "2026-01-01T00:00:00Z", + word_count: 1, + }, { + location_id: 1, + rel_path: "archive/two.md", + title: "Two", + updated_at: "2026-01-01T00:00:00Z", + word_count: 1, + }], + }), + ); + + render(); + + act(() => { + monitorArgs?.onDrop?.({ + source: { data: { type: "document", locationId: 1, relPath: "archive/one.md", title: "One" } }, + location: { + current: { + dropTargets: [{ data: { locationId: 1, relPath: "archive/two.md", targetType: "document" } }, { + data: { locationId: 1, folderPath: "archive", targetType: "folder" }, + }, { data: { locationId: 1, targetType: "location" } }], + input: { altKey: false }, + }, + }, + }); + }); + + expect(setDocuments).toHaveBeenCalledWith([{ + location_id: 1, + rel_path: "archive/two.md", + title: "Two", + updated_at: "2026-01-01T00:00:00Z", + word_count: 1, + }, { + location_id: 1, + rel_path: "archive/one.md", + title: "One", + updated_at: "2026-01-01T00:00:00Z", + word_count: 1, + }]); + }); + it("reorders documents in-place when dropped on a sibling edge", () => { const setDocuments = vi.fn(); mockExtractClosestEdge.mockReturnValue("bottom"); @@ -244,7 +949,7 @@ describe("Sidebar", () => { it("opens a move dialog for modifier-key drops and submits destination path", async () => { const handleMoveDocument = vi.fn().mockResolvedValue(true); - vi.mocked(useWorkspaceController).mockReturnValue(createWorkspaceControllerState({ handleMoveDocument })); + vi.mocked(useSidebarActions).mockReturnValue(createSidebarActionsState({ handleMoveDocument })); vi.mocked(useSidebarState).mockReturnValue( createSidebarState({ documents: [{ @@ -280,6 +985,7 @@ describe("Sidebar", () => { }); expect(handleMoveDocument).toHaveBeenCalledWith(1, "notes/test.md", "archive/test.md", 2); + expect(showSuccessToast).toHaveBeenCalledWith("Moved Test"); }); it("announces drag start for screen readers", () => { diff --git a/src/__tests__/SidebarLocationItem.test.tsx b/src/__tests__/SidebarLocationItem.test.tsx new file mode 100644 index 0000000..300e05c --- /dev/null +++ b/src/__tests__/SidebarLocationItem.test.tsx @@ -0,0 +1,41 @@ +import { canDropDocumentIntoFolder, canDropFolderIntoFolder } from "$components/Sidebar/SidebarLocationItem"; +import { describe, expect, it } from "vitest"; + +describe("canDropDocumentIntoFolder", () => { + it("rejects non-document drag payloads", () => { + expect(canDropDocumentIntoFolder(null, 1, "archive")).toBe(false); + expect(canDropDocumentIntoFolder({ type: "tab" }, 1, "archive")).toBe(false); + }); + + it("allows cross-location drops into folders", () => { + expect(canDropDocumentIntoFolder({ type: "document", locationId: 2, relPath: "nested/file.md" }, 1, "archive")) + .toBe(true); + }); + + it("blocks no-op same-folder drops", () => { + expect(canDropDocumentIntoFolder({ type: "document", locationId: 1, relPath: "archive/file.md" }, 1, "archive")) + .toBe(false); + }); + + it("allows moving from nested folder to ancestor folder", () => { + expect( + canDropDocumentIntoFolder({ type: "document", locationId: 1, relPath: "archive/2026/file.md" }, 1, "archive"), + ).toBe(true); + }); +}); + +describe("canDropFolderIntoFolder", () => { + it("blocks folder drops into itself", () => { + expect(canDropFolderIntoFolder({ type: "folder", locationId: 1, relPath: "samples" }, 1, "samples")).toBe(false); + }); + + it("blocks moving folder into one of its descendants", () => { + expect(canDropFolderIntoFolder({ type: "folder", locationId: 1, relPath: "samples" }, 1, "samples/inner")).toBe( + false, + ); + }); + + it("allows moving folder into a different sibling folder", () => { + expect(canDropFolderIntoFolder({ type: "folder", locationId: 1, relPath: "samples" }, 1, "archive")).toBe(true); + }); +}); diff --git a/src/__tests__/TreeItem.test.tsx b/src/__tests__/TreeItem.test.tsx new file mode 100644 index 0000000..69e694d --- /dev/null +++ b/src/__tests__/TreeItem.test.tsx @@ -0,0 +1,16 @@ +import { TreeItem } from "$components/Sidebar/TreeItem"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +const Icon = () => ; +const ICON_MEMO = { Component: Icon, size: "sm" as const }; + +describe("TreeItem", () => { + it("adds highlight styles when acting as a drop target", () => { + const { container } = render(); + + expect(screen.getByText("Folder")).toBeInTheDocument(); + expect(container.firstChild).toHaveClass("!bg-layer-hover-01"); + expect(container.firstChild).toHaveClass("ring-border-interactive"); + }); +}); diff --git a/src/__tests__/WorkspacePanel.test.tsx b/src/__tests__/WorkspacePanel.test.tsx index 62ee532..be0688e 100644 --- a/src/__tests__/WorkspacePanel.test.tsx +++ b/src/__tests__/WorkspacePanel.test.tsx @@ -4,6 +4,7 @@ import type { WorkspaceDiagnosticsProps, WorkspacePanelProps } from "$components import { EditorProps } from "$components/Editor"; import { PreviewProps } from "$components/Preview"; import { StatusBarProps } from "$components/StatusBar"; +import { useSidebarActions } from "$hooks/controllers/useSidebarActions"; import { useWorkspaceController } from "$hooks/controllers/useWorkspaceController"; import { useEditorPresentationState, @@ -40,6 +41,7 @@ vi.mock( useWorkspacePanelStatusBarCollapsed: vi.fn(), }), ); +vi.mock("$hooks/controllers/useSidebarActions", () => ({ useSidebarActions: vi.fn() })); vi.mock("$hooks/controllers/useWorkspaceController", () => ({ useWorkspaceController: vi.fn() })); type SelectorOverrides = { @@ -65,6 +67,7 @@ const createSidebarState = (overrides: Partial = {}): Sideba selectedLocationId: undefined, selectedDocPath: undefined, documents: [], + directories: [], isLoading: false, refreshingLocationId: undefined, sidebarRefreshReason: null, @@ -72,6 +75,7 @@ const createSidebarState = (overrides: Partial = {}): Sideba filterText: "", setFilterText: vi.fn(), setDocuments: vi.fn(), + setDirectories: vi.fn(), selectLocation: vi.fn(), toggleSidebarCollapsed: vi.fn(), filenameVisibility: false, @@ -162,10 +166,23 @@ const mockPanelSelectors = (overrides: SelectorOverrides = {}): void => { handleRefreshSidebar: vi.fn(), handleRenameDocument: vi.fn(), handleMoveDocument: vi.fn(), + handleMoveDirectory: vi.fn(), handleDeleteDocument: vi.fn(), handleCreateDirectory: vi.fn(), handleImportExternalFile: vi.fn(), }); + vi.mocked(useSidebarActions).mockReturnValue({ + handleAddLocation: vi.fn(), + handleRemoveLocation: vi.fn(), + handleSelectDocument: vi.fn(), + handleCreateNewDocument: vi.fn(), + handleRefreshSidebar: vi.fn(), + handleRenameDocument: vi.fn(), + handleMoveDocument: vi.fn(), + handleMoveDirectory: vi.fn(), + handleDeleteDocument: vi.fn(), + handleImportExternalFile: vi.fn(), + }); }; const createWorkspacePanelProps = (overrides: WorkspacePanelPropOverrides = {}): WorkspacePanelProps => ({ diff --git a/src/__tests__/dnd.test.ts b/src/__tests__/dnd.test.ts new file mode 100644 index 0000000..8ebef20 --- /dev/null +++ b/src/__tests__/dnd.test.ts @@ -0,0 +1,186 @@ +import { + announce, + attachClosestEdge, + cleanup, + draggable, + dropTargetForElements, + extractClosestEdge, + monitorForElements, + normalizePointerCoordinates, + resolveDestinationFromPointer, +} from "$dnd"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +function withDragEvent( + type: string, + options: { clientX?: number; clientY?: number; altKey?: boolean } = {}, +): DragEvent { + const event = new Event(type, { bubbles: true, cancelable: true }) as DragEvent; + Object.defineProperties(event, { + clientX: { value: options.clientX ?? 0, configurable: true }, + clientY: { value: options.clientY ?? 0, configurable: true }, + x: { value: options.clientX ?? 0, configurable: true }, + y: { value: options.clientY ?? 0, configurable: true }, + altKey: { value: options.altKey ?? false, configurable: true }, + dataTransfer: { value: { effectAllowed: "all", dropEffect: "none", setData: vi.fn() }, configurable: true }, + }); + return event; +} + +afterEach(() => { + cleanup(); + document.body.innerHTML = ""; + vi.restoreAllMocks(); +}); + +describe("dnd edge helpers", () => { + it("attaches and extracts a top edge", () => { + const element = document.createElement("div"); + element.getBoundingClientRect = () => + ({ + left: 0, + right: 100, + top: 100, + bottom: 200, + width: 100, + height: 100, + x: 0, + y: 100, + toJSON: () => ({}), + }) as DOMRect; + + const destination = attachClosestEdge({ locationId: 1, targetType: "document", relPath: "a.md" }, { + input: { clientX: 12, clientY: 110, x: 12, y: 110, altKey: false }, + element, + allowedEdges: ["top", "bottom"], + }); + + expect(extractClosestEdge(destination)).toBe("top"); + }); +}); + +describe("resolveDestinationFromPointer", () => { + it("prefers direct document hits from elementFromPoint", () => { + const row = document.createElement("div"); + row.dataset.locationId = "7"; + row.dataset.documentPath = "notes/file.md"; + const child = document.createElement("span"); + row.append(child); + document.body.append(row); + + vi.spyOn(document, "elementFromPoint").mockReturnValue(child); + + const resolved = resolveDestinationFromPointer(50, 50); + expect(resolved?.destination.locationId).toBe(7); + expect(resolved?.destination.targetType).toBe("document"); + expect(resolved?.destination.relPath).toBe("notes/file.md"); + }); + + it("falls back to nearest location target when pointer is near but outside rows", () => { + vi.spyOn(document, "elementFromPoint").mockReturnValue(null); + + const row = document.createElement("div"); + row.dataset.dropFolderRow = "true"; + row.dataset.locationId = "3"; + row.dataset.folderPath = "archive"; + row.getBoundingClientRect = () => + ({ + left: 0, + right: 400, + top: 220, + bottom: 260, + width: 400, + height: 40, + x: 0, + y: 220, + toJSON: () => ({}), + }) as DOMRect; + document.body.append(row); + + const resolved = resolveDestinationFromPointer(120, 270); + expect(resolved?.destination.locationId).toBe(3); + expect(resolved?.destination.targetType).toBe("location"); + }); + + it("preserves folder targeting in geometry fallback", () => { + vi.spyOn(document, "elementFromPoint").mockReturnValue(null); + + const folderRow = document.createElement("div"); + folderRow.dataset.dropFolderRow = "true"; + folderRow.dataset.locationId = "4"; + folderRow.dataset.folderPath = "projects/writer"; + folderRow.getBoundingClientRect = () => + ({ + left: 0, + right: 400, + top: 300, + bottom: 340, + width: 400, + height: 40, + x: 0, + y: 300, + toJSON: () => ({}), + }) as DOMRect; + document.body.append(folderRow); + + const resolved = resolveDestinationFromPointer(100, 320); + expect(resolved?.destination.locationId).toBe(4); + expect(resolved?.destination.targetType).toBe("folder"); + expect(resolved?.destination.folderPath).toBe("projects/writer"); + }); +}); + +describe("native drag lifecycle", () => { + it("monitors drag start, target change, and drop", () => { + const sourceElement = document.createElement("div"); + const dropZoneElement = document.createElement("div"); + document.body.append(sourceElement, dropZoneElement); + + const onDropTargetChange = vi.fn(); + const onDrop = vi.fn(); + + const stopDraggable = draggable({ + element: sourceElement, + getInitialData: () => ({ type: "document", locationId: 1, relPath: "a.md", title: "A" }), + }); + const stopDropTarget = dropTargetForElements({ + element: dropZoneElement, + canDrop: () => true, + getData: () => ({ locationId: 2, targetType: "location" }), + }); + const stopMonitor = monitorForElements({ onDropTargetChange, onDrop }); + + sourceElement.dispatchEvent(withDragEvent("dragstart", { clientX: 12, clientY: 20 })); + dropZoneElement.dispatchEvent(withDragEvent("dragover", { clientX: 24, clientY: 40 })); + dropZoneElement.dispatchEvent(withDragEvent("drop", { clientX: 24, clientY: 40 })); + + expect(onDropTargetChange).toHaveBeenCalledTimes(1); + expect(onDrop).toHaveBeenCalledTimes(1); + expect(onDrop.mock.calls[0][0].location.current.dropTargets[0].data).toEqual({ + locationId: 2, + targetType: "location", + }); + + stopMonitor(); + stopDropTarget(); + stopDraggable(); + }); +}); + +describe("normalizePointerCoordinates", () => { + it("normalizes physical pixel coordinates to viewport coordinates when needed", () => { + Object.defineProperty(globalThis, "innerWidth", { value: 600, configurable: true }); + Object.defineProperty(globalThis, "innerHeight", { value: 400, configurable: true }); + Object.defineProperty(globalThis, "devicePixelRatio", { value: 2, configurable: true }); + + const point = normalizePointerCoordinates(1000, 500); + expect(point).toEqual({ x: 500, y: 250 }); + }); +}); + +describe("announce", () => { + it("creates a live region for screen reader updates", () => { + announce("Dragging file"); + expect(document.querySelector("#writer-dnd-live-region")).toBeTruthy(); + }); +}); diff --git a/src/__tests__/useExternalDropHandler.test.tsx b/src/__tests__/useExternalDropHandler.test.tsx index cc4d613..70f0fc5 100644 --- a/src/__tests__/useExternalDropHandler.test.tsx +++ b/src/__tests__/useExternalDropHandler.test.tsx @@ -1,4 +1,3 @@ -import { useWorkspaceController } from "$hooks/controllers/useWorkspaceController"; import { useExternalDropHandler } from "$hooks/useExternalDropHandler"; import { showSuccessToast, showWarnToast } from "$state/stores/toasts"; import type { DocMeta } from "$types"; @@ -7,8 +6,6 @@ import { readTextFile } from "@tauri-apps/plugin-fs"; import { act, renderHook } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("$hooks/controllers/useWorkspaceController", () => ({ useWorkspaceController: vi.fn() })); - const DOCS: DocMeta[] = [{ location_id: 1, rel_path: "existing.md", @@ -20,11 +17,6 @@ const DOCS: DocMeta[] = [{ describe("useExternalDropHandler", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(useWorkspaceController).mockReturnValue( - { handleImportExternalFile: vi.fn().mockResolvedValue(true) } as unknown as ReturnType< - typeof useWorkspaceController - >, - ); }); it("imports markdown files, skips conflicts, and refreshes once", async () => { @@ -40,11 +32,8 @@ describe("useExternalDropHandler", () => { const setExternalDropTarget = vi.fn(); const refreshSidebar = vi.fn(); const handleImportExternalFile = vi.fn().mockResolvedValue(true); - vi.mocked(useWorkspaceController).mockReturnValue( - { handleImportExternalFile } as unknown as ReturnType, - ); - renderHook(() => useExternalDropHandler(1, DOCS, setExternalDropTarget, refreshSidebar)); + renderHook(() => useExternalDropHandler(1, DOCS, setExternalDropTarget, refreshSidebar, handleImportExternalFile)); await act(async () => { await dragDropListener?.({ @@ -81,13 +70,11 @@ describe("useExternalDropHandler", () => { const setExternalDropTarget = vi.fn(); const refreshSidebar = vi.fn(); const handleImportExternalFile = vi.fn().mockResolvedValue(true); - vi.mocked(useWorkspaceController).mockReturnValue( - { handleImportExternalFile } as unknown as ReturnType, - ); - renderHook(() => useExternalDropHandler(1, DOCS, setExternalDropTarget, refreshSidebar)); + renderHook(() => useExternalDropHandler(1, DOCS, setExternalDropTarget, refreshSidebar, handleImportExternalFile)); await act(async () => { + await dragDropListener?.({ payload: { type: "enter", position: { x: 12, y: 18 }, paths: ["/tmp/entry.md"] } }); await dragDropListener?.({ payload: { type: "over", position: { x: 12, y: 18 } } }); await dragDropListener?.({ payload: { type: "drop", position: { x: 12, y: 18 }, paths: ["/tmp/entry.md"] } }); }); @@ -97,4 +84,32 @@ describe("useExternalDropHandler", () => { expect(handleImportExternalFile).toHaveBeenCalledWith(2, "archive/2026/entry.md", "# moved by drop"); expect(refreshSidebar).toHaveBeenCalledWith(2); }); + + it("ignores non-file drops from internal drag and drop", async () => { + let dragDropListener: ((event: { payload: unknown }) => Promise) | undefined; + vi.mocked(getCurrentWindow).mockReturnValue({ + onDragDropEvent: vi.fn((listener) => { + dragDropListener = listener; + return Promise.resolve(() => {}); + }), + } as never); + + const setExternalDropTarget = vi.fn(); + const refreshSidebar = vi.fn(); + const handleImportExternalFile = vi.fn().mockResolvedValue(true); + + renderHook(() => useExternalDropHandler(1, DOCS, setExternalDropTarget, refreshSidebar, handleImportExternalFile)); + + await act(async () => { + await dragDropListener?.({ payload: { type: "enter", position: { x: 4, y: 8 }, paths: [] } }); + await dragDropListener?.({ payload: { type: "over", position: { x: 4, y: 8 } } }); + await dragDropListener?.({ payload: { type: "drop", position: { x: 4, y: 8 }, paths: [] } }); + }); + + expect(handleImportExternalFile).not.toHaveBeenCalled(); + expect(refreshSidebar).not.toHaveBeenCalled(); + expect(showWarnToast).not.toHaveBeenCalled(); + expect(showSuccessToast).not.toHaveBeenCalled(); + expect(setExternalDropTarget).toHaveBeenCalledWith(undefined); + }); }); diff --git a/src/__tests__/useWorkspaceController.test.tsx b/src/__tests__/useWorkspaceController.test.tsx index 83cd254..7713641 100644 --- a/src/__tests__/useWorkspaceController.test.tsx +++ b/src/__tests__/useWorkspaceController.test.tsx @@ -1,5 +1,7 @@ import { useWorkspaceController } from "$hooks/controllers/useWorkspaceController"; import { + dirList, + dirMove, docDelete, docList, docMove, @@ -23,6 +25,10 @@ vi.mock( docList: vi.fn((_locationId: number, _onOk: (docs: unknown[]) => void, _onErr: (error: unknown) => void) => ({ type: "None", })), + dirList: vi.fn((_locationId: number, _onOk: (dirs: string[]) => void, _onErr: (error: unknown) => void) => ({ + type: "None", + })), + dirMove: vi.fn(() => ({ type: "None" })), docDelete: vi.fn(() => ({ type: "None" })), docMove: vi.fn(() => ({ type: "None" })), docRename: vi.fn(() => ({ type: "None" })), @@ -77,6 +83,7 @@ describe("useWorkspaceController", () => { }); expect(docList).toHaveBeenCalledWith(1, expect.any(Function), expect.any(Function)); + expect(dirList).toHaveBeenCalledWith(1, expect.any(Function), expect.any(Function)); expect(runCmd).toHaveBeenCalled(); }); @@ -217,4 +224,50 @@ describe("useWorkspaceController", () => { expect.any(Function), ); }); + + it("updates open tabs under a moved directory", async () => { + useTabsStore.setState({ + tabs: [{ + id: "tab-1", + docRef: { location_id: 1, rel_path: "Samples/some-file.md" }, + title: "Some File", + isModified: false, + }, { + id: "tab-2", + docRef: { location_id: 1, rel_path: "Samples/nested/deeper.md" }, + title: "Deeper", + isModified: false, + }], + activeTabId: "tab-1", + isSessionHydrated: true, + }); + vi.mocked(dirMove).mockImplementation((_locationId, _relPath, _newRelPath, onOk) => { + onOk("Archive/Samples"); + return { type: "None" }; + }); + + const { result } = renderHook(() => useWorkspaceController()); + + const moved = await act(async () => { + return await result.current.handleMoveDirectory(1, "Samples", "Archive/Samples"); + }); + + expect(moved).toBeTruthy(); + expect(sessionUpdateTabDoc).toHaveBeenCalledWith( + 1, + "Samples/some-file.md", + { location_id: 1, rel_path: "Archive/Samples/some-file.md" }, + "Some File", + expect.any(Function), + expect.any(Function), + ); + expect(sessionUpdateTabDoc).toHaveBeenCalledWith( + 1, + "Samples/nested/deeper.md", + { location_id: 1, rel_path: "Archive/Samples/nested/deeper.md" }, + "Deeper", + expect.any(Function), + expect.any(Function), + ); + }); }); diff --git a/src/components/AppLayout/AppHeaderBar.tsx b/src/components/AppLayout/AppHeaderBar.tsx index 8517713..f5923ee 100644 --- a/src/components/AppLayout/AppHeaderBar.tsx +++ b/src/components/AppLayout/AppHeaderBar.tsx @@ -13,7 +13,7 @@ const AppTitle = ({ hideTitle, version }: { hideTitle: boolean; version: string {hideTitle ? null : ( -
+

Writer

diff --git a/src/components/CollapsibleSection.tsx b/src/components/CollapsibleSection.tsx index a6980df..15d1b35 100644 --- a/src/components/CollapsibleSection.tsx +++ b/src/components/CollapsibleSection.tsx @@ -52,12 +52,19 @@ export const CollapsibleSection = ( CollapsibleSectionProps, ) => { const [internalOpen, setInternalOpen] = useState(defaultOpen); + const [isAnimatingPanel, setIsAnimatingPanel] = useState(false); const sectionId = useId(); const skipAnimation = useSkipAnimation(); const buttonId = useMemo(() => `${sectionId}-trigger`, [sectionId]); const panelId = useMemo(() => `${sectionId}-panel`, [sectionId]); const isControlled = useMemo(() => typeof open === "boolean", [open]); const isOpen = useMemo(() => isControlled ? open : internalOpen, [isControlled, internalOpen, open]); + const handlePanelAnimationStart = useCallback(() => { + setIsAnimatingPanel(true); + }, []); + const handlePanelAnimationComplete = useCallback(() => { + setIsAnimatingPanel(false); + }, []); const handleToggle = useCallback(() => { const next = !isOpen; @@ -89,7 +96,13 @@ export const CollapsibleSection = ( id={panelId} role="region" aria-labelledby={buttonId} - className={cn("overflow-hidden pb-1", contentClassName)} + className={cn( + "overflow-hidden pb-1", + isAnimatingPanel ? "will-change-[height,opacity]" : "", + contentClassName, + )} + onAnimationStart={handlePanelAnimationStart} + onAnimationComplete={handlePanelAnimationComplete} {...getPanelAnimationProps(skipAnimation)}> {children} diff --git a/src/components/ContextMenu/ContextMenu.tsx b/src/components/ContextMenu/ContextMenu.tsx index 3461bba..a7abdac 100644 --- a/src/components/ContextMenu/ContextMenu.tsx +++ b/src/components/ContextMenu/ContextMenu.tsx @@ -152,14 +152,18 @@ export function useContextMenu() { position: { x: 0, y: 0 }, }); + const openAt = useCallback((x: number, y: number) => { + setState({ isOpen: true, position: { x, y } }); + }, []); + const open = useCallback((e: React.MouseEvent) => { e.preventDefault(); - setState({ isOpen: true, position: { x: e.clientX, y: e.clientY } }); - }, []); + openAt(e.clientX, e.clientY); + }, [openAt]); const close = useCallback(() => { setState((prev) => ({ ...prev, isOpen: false })); }, []); - return { ...state, open, close }; + return { ...state, open, openAt, close }; } diff --git a/src/components/DocumentTabs/CloseTabButton.tsx b/src/components/DocumentTabs/CloseTabButton.tsx index 9836c43..8a191a5 100644 --- a/src/components/DocumentTabs/CloseTabButton.tsx +++ b/src/components/DocumentTabs/CloseTabButton.tsx @@ -9,7 +9,7 @@ export const CloseTabButton = ( variant="iconGhost" size="iconXs" onClick={handleCloseTabClick} - className="tab-close-btn shrink-0 opacity-0 transition-all duration-150 group-hover:opacity-100 hover:text-icon-primary hover:bg-layer-hover-01" + className="tab-close-btn shrink-0 opacity-0 transition-[opacity,color,background-color] duration-150 group-hover:opacity-100 hover:text-icon-primary hover:bg-layer-hover-01" title="Close tab"> diff --git a/src/components/DocumentTabs/DocumentTab.tsx b/src/components/DocumentTabs/DocumentTab.tsx index 5400744..7e8820b 100644 --- a/src/components/DocumentTabs/DocumentTab.tsx +++ b/src/components/DocumentTabs/DocumentTab.tsx @@ -73,7 +73,7 @@ export const DocumentTab = ( const classes = useMemo(() => { const base = [ - "group flex items-center gap-1.5 px-2.5 shrink-0 cursor-pointer border-r border-border-subtle select-none transition-all duration-150", + "group flex items-center gap-1.5 px-2.5 shrink-0 cursor-pointer border-r border-border-subtle select-none transition-colors duration-150", ]; if (compact) { diff --git a/src/components/QuickCapture/QuickCaptureForm.tsx b/src/components/QuickCapture/QuickCaptureForm.tsx index 9b261ed..84dd5d2 100644 --- a/src/components/QuickCapture/QuickCaptureForm.tsx +++ b/src/components/QuickCapture/QuickCaptureForm.tsx @@ -39,7 +39,7 @@ const ModeButton = ({ currentMode, setMode, isSubmitting, mode }: ModeButtonProp const classes = useMemo( () => cn( - "px-2.5 py-1.5 text-xs sm:text-sm font-medium border rounded transition-all", + "px-2.5 py-1.5 text-xs sm:text-sm font-medium border rounded transition-[color,background-color,opacity,border-color] duration-150", mode === currentMode ? "bg-accent-blue text-white border-accent-blue" : "bg-field-02 text-text-secondary border-border-subtle hover:bg-field-hover-02 hover:text-text-primary", @@ -78,7 +78,7 @@ const FooterActions = ( @@ -191,7 +191,7 @@ function SearchInput({ query, handleQueryChange, clearQuery, compact = false }: onChange={handleQueryChange} placeholder="Search across all documents..." autoFocus - className={`w-full pl-10 pr-3 text-base bg-field-01 border border-border-subtle rounded-md text-text-primary outline-none transition-all duration-150 focus:border-border-interactive focus:shadow-[0_0_0_3px_rgba(69,137,255,0.2)] ${ + className={`w-full pl-10 pr-3 text-base bg-field-01 border border-border-subtle rounded-md text-text-primary outline-none transition-[border-color,background-color,box-shadow] duration-150 focus:border-border-interactive focus:shadow-[0_0_0_3px_rgba(69,137,255,0.2)] ${ compact ? "py-2" : "py-2.5" }`} /> {query && ( diff --git a/src/components/Sidebar/AddButton.tsx b/src/components/Sidebar/AddButton.tsx index 448efb1..28f17ec 100644 --- a/src/components/Sidebar/AddButton.tsx +++ b/src/components/Sidebar/AddButton.tsx @@ -15,7 +15,7 @@ export const AddButton = ( size="iconMd" onClick={onClick} disabled={disabled} - className="transition-all duration-150 hover:bg-layer-hover-01 hover:text-icon-primary" + className="transition-colors duration-150 hover:bg-layer-hover-01 hover:text-icon-primary" title={title}> diff --git a/src/components/Sidebar/DnDMoveDialog.tsx b/src/components/Sidebar/DnDMoveDialog.tsx new file mode 100644 index 0000000..512ebb1 --- /dev/null +++ b/src/components/Sidebar/DnDMoveDialog.tsx @@ -0,0 +1,53 @@ +import type { ChangeEventHandler, FormEvent } from "react"; +import { useMemo } from "react"; +import { OperationDialog } from "./OperationDialog"; + +type DnDMoveDialogProps = { + isOpen: boolean; + onClose: () => void; + entityLabel: "document" | "folder"; + formId: string; + path: string; + onPathChange: ChangeEventHandler; + onSubmit: (event: FormEvent) => Promise; + isPending: boolean; + confirmDisabled: boolean; +}; + +export function DnDMoveDialog( + { isOpen, onClose, entityLabel, formId, path, onPathChange, onSubmit, isPending, confirmDisabled }: + DnDMoveDialogProps, +) { + const confirmAction = useMemo(() => ({ type: "submit" as const, formId }), [formId]); + + return ( + +
+ + +
+
+ ); +} diff --git a/src/components/Sidebar/DocumentItem.tsx b/src/components/Sidebar/DocumentItem.tsx index 0e55747..3eb8f2d 100644 --- a/src/components/Sidebar/DocumentItem.tsx +++ b/src/components/Sidebar/DocumentItem.tsx @@ -1,19 +1,14 @@ import { ContextMenu, ContextMenuDivider, ContextMenuItem, useContextMenu } from "$components/ContextMenu"; +import { draggable, type Edge } from "$dnd"; import { useSkipAnimation } from "$hooks/useMotion"; import { ClipboardIcon, EditIcon, FileTextIcon, FolderIcon, TrashIcon } from "$icons"; import type { DocMeta } from "$types"; import { f } from "$utils/serialize"; import { cn } from "$utils/tw"; -import { - attachClosestEdge, - type Edge, - extractClosestEdge, -} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"; -import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; -import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; import * as logger from "@tauri-apps/plugin-log"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { type DialogAnchor, OperationDialog } from "./OperationDialog"; +import { type DocumentOperationType } from "./DocumentOperationDialog"; +import type { DialogAnchor } from "./OperationDialog"; import { TreeItem } from "./TreeItem"; export type DocumentDragData = { type: "document"; locationId: number; relPath: string; title: string }; @@ -23,296 +18,72 @@ const FILE_TEXT_ICON = { Component: FileTextIcon, size: "sm" as const }; type DocumentItemProps = { doc: DocMeta; isSelected: boolean; - selectedDocPath?: string; level?: number; - onSelectDocument: (id: number, path: string) => void; - onRenameDocument: (locationId: number, relPath: string, newName: string) => Promise; - onMoveDocument: (locationId: number, relPath: string, newRelPath: string) => Promise; - onDeleteDocument: (locationId: number, relPath: string) => Promise; + onSelectDocument: (locationId: number, path: string) => void; + onOpenDocumentOperation: (type: DocumentOperationType, doc: DocMeta, anchor?: DialogAnchor) => void; filenameVisibility: boolean; - id: number; + activeDropDocumentPath?: string; + activeDropDocumentEdge?: Edge | null; }; -type RenameDialogProps = { - isOpen: boolean; - onClose: () => void; - currentName: string; - onRename: (newName: string) => Promise; - anchor?: DialogAnchor; -}; - -type MoveDialogProps = { - isOpen: boolean; - onClose: () => void; - currentPath: string; - onMove: (newPath: string) => Promise; - anchor?: DialogAnchor; -}; - -type DeleteConfirmDialogProps = { - isOpen: boolean; - onClose: () => void; - documentName: string; - onDelete: () => Promise; - anchor?: DialogAnchor; -}; - -function RenameDialog({ isOpen, onClose, currentName, onRename, anchor }: RenameDialogProps) { - const [name, setName] = useState(currentName); - const [isRenaming, setIsRenaming] = useState(false); - const formId = "rename-document-form"; - const inputId = "rename-document-input"; - - const handleSubmit = useCallback(async (e: React.FormEvent) => { - e.preventDefault(); - if (!name.trim() || name === currentName) { - onClose(); - return; - } - setIsRenaming(true); - try { - const renamed = await onRename(name.trim()); - if (renamed) { - onClose(); - } - } finally { - setIsRenaming(false); - } - }, [name, currentName, onRename, onClose]); - - const handleInputChange = useCallback((e: React.ChangeEvent) => { - setName(e.target.value); - }, []); - - useEffect(() => { - if (isOpen) { - setName(currentName); - } - }, [isOpen, currentName]); - - return ( - -
- - -
-
- ); -} - -function MoveDialog({ isOpen, onClose, currentPath, onMove, anchor }: MoveDialogProps) { - const [path, setPath] = useState(currentPath); - const [isMoving, setIsMoving] = useState(false); - const formId = "move-document-form"; - const inputId = "move-document-input"; - - const handleSubmit = useCallback(async (e: React.FormEvent) => { - e.preventDefault(); - if (!path.trim() || path === currentPath) { - onClose(); - return; - } - setIsMoving(true); - try { - const moved = await onMove(path.trim()); - if (moved) { - onClose(); - } - } finally { - setIsMoving(false); - } - }, [path, currentPath, onMove, onClose]); - - const handleInputChange = useCallback((e: React.ChangeEvent) => { - setPath(e.target.value); - }, []); - - useEffect(() => { - if (isOpen) { - setPath(currentPath); - } - }, [isOpen, currentPath]); - - return ( - -
- - -

Enter the new relative path for the document.

-
-
- ); -} - -function DeleteConfirmDialog({ isOpen, onClose, documentName, onDelete, anchor }: DeleteConfirmDialogProps) { - const [isDeleting, setIsDeleting] = useState(false); - - const handleDelete = useCallback(async () => { - setIsDeleting(true); - try { - const deleted = await onDelete(); - if (deleted) { - onClose(); - } - } finally { - setIsDeleting(false); - } - }, [onDelete, onClose]); - - return ( - -

- Are you sure you want to delete{" "} - {documentName}? This action cannot be undone. -

-
- ); -} - export function DocumentItem( { doc, isSelected, - selectedDocPath, level = 1, onSelectDocument, - onRenameDocument, - onMoveDocument, - onDeleteDocument, + onOpenDocumentOperation, filenameVisibility, - id, + activeDropDocumentPath, + activeDropDocumentEdge, }: DocumentItemProps, ) { const { isOpen, position, open, close } = useContextMenu(); - const [showRenameDialog, setShowRenameDialog] = useState(false); - const [showMoveDialog, setShowMoveDialog] = useState(false); - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const [operationAnchor, setOperationAnchor] = useState(); const treeItemRef = useRef(null); const [dragState, setDragState] = useState<"idle" | "dragging">("idle"); - const [dropState, setDropState] = useState<"idle" | "over">("idle"); - const [closestEdge, setClosestEdge] = useState(null); const skipAnimation = useSkipAnimation(); + const locationId = doc.location_id; useEffect(() => { const element = treeItemRef.current; - if (!element) return; + if (!element) { + return; + } - return combine( - draggable({ - element, - getInitialData: (): DocumentDragData => ({ - type: "document", - locationId: id, - relPath: doc.rel_path, - title: doc.title || doc.rel_path.split("/").pop() || "Untitled", - }), - onDragStart: () => setDragState("dragging"), - onDrop: () => setDragState("idle"), + return draggable({ + element, + getInitialData: (): DocumentDragData => ({ + type: "document", + locationId, + relPath: doc.rel_path, + title: doc.title || doc.rel_path.split("/").pop() || "Untitled", }), - dropTargetForElements({ - element, - canDrop: ({ source }) => { - const data = source.data as DocumentDragData; - return data.type === "document" && data.locationId === id && data.relPath !== doc.rel_path; - }, - getData: ({ input }) => - attachClosestEdge({ locationId: id, relPath: doc.rel_path, targetType: "document" as const }, { - input, - element, - allowedEdges: ["top", "bottom"], - }), - onDragEnter: (args) => { - setDropState("over"); - setClosestEdge(extractClosestEdge(args.self.data)); - }, - onDrag: (args) => { - setClosestEdge(extractClosestEdge(args.self.data)); - }, - onDragLeave: () => { - setDropState("idle"); - setClosestEdge(null); - }, - onDrop: () => { - setDropState("idle"); - setClosestEdge(null); - }, - }), - ); - }, [id, doc.rel_path, doc.title]); + onDragStart: () => setDragState("dragging"), + onDrop: () => setDragState("idle"), + }); + }, [locationId, doc.rel_path, doc.title]); const displayLabel = useMemo(() => { if (filenameVisibility) { return doc.rel_path.split("/").pop() || "Untitled"; } + return doc.title || doc.rel_path.split("/").pop() || "Untitled"; }, [doc.title, doc.rel_path, filenameVisibility]); - const handleClick = useCallback(() => onSelectDocument(id, doc.rel_path), [id, onSelectDocument, doc.rel_path]); + const handleClick = useCallback(() => { + onSelectDocument(locationId, doc.rel_path); + }, [doc.rel_path, locationId, onSelectDocument]); - const handleContextMenu = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - open(e); + const handleContextMenu = useCallback((event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + open(event); }, [open]); const handleOpen = useCallback(() => { - onSelectDocument(id, doc.rel_path); - }, [id, doc.rel_path, onSelectDocument]); + onSelectDocument(locationId, doc.rel_path); + }, [locationId, doc.rel_path, onSelectDocument]); const handleCopyPath = useCallback(async () => { try { @@ -322,50 +93,14 @@ export function DocumentItem( } }, [doc.rel_path]); - const handleRename = useCallback(() => { - setOperationAnchor({ x: position.x, y: position.y }); - close(); - setShowRenameDialog(true); - }, [close, position.x, position.y]); - - const handleMove = useCallback(() => { - setOperationAnchor({ x: position.x, y: position.y }); - close(); - setShowMoveDialog(true); - }, [close, position.x, position.y]); - - const handleDeleteClick = useCallback(() => { - setOperationAnchor({ x: position.x, y: position.y }); + const openOperation = useCallback((type: DocumentOperationType) => { + onOpenDocumentOperation(type, doc, { x: position.x, y: position.y }); close(); - setShowDeleteDialog(true); - }, [close, position.x, position.y]); - - const performRename = useCallback((newName: string) => { - return onRenameDocument(id, doc.rel_path, newName); - }, [id, doc.rel_path, onRenameDocument]); - - const performMove = useCallback((newRelPath: string) => { - return onMoveDocument(id, doc.rel_path, newRelPath); - }, [id, doc.rel_path, onMoveDocument]); - - const performDelete = useCallback(() => { - return onDeleteDocument(id, doc.rel_path); - }, [id, doc.rel_path, onDeleteDocument]); + }, [close, doc, onOpenDocumentOperation, position.x, position.y]); - const closeRenameDialog = useCallback(() => { - setShowRenameDialog(false); - setOperationAnchor(undefined); - }, []); - - const closeMoveDialog = useCallback(() => { - setShowMoveDialog(false); - setOperationAnchor(undefined); - }, []); - - const closeDeleteDialog = useCallback(() => { - setShowDeleteDialog(false); - setOperationAnchor(undefined); - }, []); + const handleRename = useCallback(() => openOperation("rename"), [openOperation]); + const handleMove = useCallback(() => openOperation("move"), [openOperation]); + const handleDeleteClick = useCallback(() => openOperation("delete"), [openOperation]); const contextMenuItems = useMemo<(ContextMenuItem | ContextMenuDivider)[]>( () => [ @@ -377,55 +112,46 @@ export function DocumentItem( { divider: true }, { label: "Delete", onClick: handleDeleteClick, icon: , danger: true }, ], - [handleOpen, handleCopyPath, handleRename, handleMove, handleDeleteClick], + [handleCopyPath, handleDeleteClick, handleMove, handleOpen, handleRename], ); - const currentFilename = useMemo(() => doc.rel_path.split("/").pop() || "", [doc.rel_path]); + const isActiveDropDocument = activeDropDocumentPath === doc.rel_path; + const closestEdge = isActiveDropDocument ? activeDropDocumentEdge ?? null : null; const edgeStyle = useMemo(() => { - if (!closestEdge) return {}; + if (!closestEdge) { + return {}; + } return { [closestEdge === "top" ? "top" : "bottom"]: "-1px" }; }, [closestEdge]); return ( <> -
+
+ isDropTarget={isActiveDropDocument} /> {closestEdge && (
)}
- - - ); } diff --git a/src/components/Sidebar/DocumentOperationDialog.tsx b/src/components/Sidebar/DocumentOperationDialog.tsx new file mode 100644 index 0000000..111cc12 --- /dev/null +++ b/src/components/Sidebar/DocumentOperationDialog.tsx @@ -0,0 +1,228 @@ +import type { DocMeta } from "$types"; +import type { ChangeEvent, FormEvent } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { type DialogAnchor, OperationDialog } from "./OperationDialog"; + +export type DocumentOperationType = "rename" | "move" | "delete"; + +export type DocumentOperationRequest = { type: DocumentOperationType; doc: DocMeta; anchor?: DialogAnchor }; + +type DocumentOperationDialogProps = { + operation: DocumentOperationRequest | null; + onClose: () => void; + onRenameDocument: (locationId: number, relPath: string, newName: string) => Promise; + onMoveDocument: (locationId: number, relPath: string, newRelPath: string) => Promise; + onDeleteDocument: (locationId: number, relPath: string) => Promise; + onRefreshSidebar: (locationId?: number) => void; +}; + +export function DocumentOperationDialog( + { operation, onClose, onRenameDocument, onMoveDocument, onDeleteDocument, onRefreshSidebar }: + DocumentOperationDialogProps, +) { + const [renameName, setRenameName] = useState(""); + const [movePath, setMovePath] = useState(""); + const [isPending, setIsPending] = useState(false); + + const activeDoc = operation?.doc ?? null; + const operationType = operation?.type; + const currentFilename = useMemo(() => activeDoc?.rel_path.split("/").pop() || "", [activeDoc?.rel_path]); + const currentPath = activeDoc?.rel_path ?? ""; + const displayLabel = useMemo(() => { + if (!activeDoc) { + return ""; + } + return activeDoc.title || activeDoc.rel_path.split("/").pop() || "Untitled"; + }, [activeDoc]); + const renameConfirm = useMemo(() => ({ type: "submit" as const, formId: "rename-document-form" }), []); + const moveConfirm = useMemo(() => ({ type: "submit" as const, formId: "move-document-form" }), []); + + useEffect(() => { + if (!operation) { + return; + } + + setRenameName(operation.doc.rel_path.split("/").pop() || ""); + setMovePath(operation.doc.rel_path); + setIsPending(false); + }, [operation]); + + const closeDialog = useCallback(() => { + if (!isPending) { + onClose(); + } + }, [isPending, onClose]); + + const handleRenameSubmit = useCallback(async (event: FormEvent) => { + event.preventDefault(); + if (!activeDoc) { + return; + } + + const nextName = renameName.trim(); + if (!nextName || nextName === currentFilename) { + closeDialog(); + return; + } + + setIsPending(true); + try { + const renamed = await onRenameDocument(activeDoc.location_id, activeDoc.rel_path, nextName); + if (!renamed) { + return; + } + + onRefreshSidebar(activeDoc.location_id); + onClose(); + } finally { + setIsPending(false); + } + }, [activeDoc, closeDialog, currentFilename, onClose, onRefreshSidebar, onRenameDocument, renameName]); + + const handleMoveSubmit = useCallback(async (event: FormEvent) => { + event.preventDefault(); + if (!activeDoc) { + return; + } + + const nextPath = movePath.trim(); + if (!nextPath || nextPath === currentPath) { + closeDialog(); + return; + } + + setIsPending(true); + try { + const moved = await onMoveDocument(activeDoc.location_id, activeDoc.rel_path, nextPath); + if (!moved) { + return; + } + + onRefreshSidebar(activeDoc.location_id); + onClose(); + } finally { + setIsPending(false); + } + }, [activeDoc, closeDialog, currentPath, movePath, onClose, onMoveDocument, onRefreshSidebar]); + + const handleDelete = useCallback(async () => { + if (!activeDoc) { + return; + } + + setIsPending(true); + try { + const deleted = await onDeleteDocument(activeDoc.location_id, activeDoc.rel_path); + if (!deleted) { + return; + } + + onRefreshSidebar(activeDoc.location_id); + onClose(); + } finally { + setIsPending(false); + } + }, [activeDoc, onClose, onDeleteDocument, onRefreshSidebar]); + + const deleteConfirm = useMemo(() => ({ type: "action" as const, onConfirm: handleDelete }), [handleDelete]); + + const handleRenameNameChange = useCallback((event: ChangeEvent) => { + setRenameName(event.target.value); + }, []); + + const handleMovePathChange = useCallback((event: ChangeEvent) => { + setMovePath(event.target.value); + }, []); + + if (!operation || !activeDoc || !operationType) { + return null; + } + + if (operationType === "rename") { + return ( + +
+ + +
+
+ ); + } + + if (operationType === "move") { + return ( + +
+ + +

Enter the new relative path for the document.

+
+
+ ); + } + + return ( + +

+ Are you sure you want to delete{" "} + {displayLabel}? This action cannot be undone. +

+
+ ); +} diff --git a/src/components/Sidebar/OperationDialog.tsx b/src/components/Sidebar/OperationDialog.tsx index c118615..eae96f5 100644 --- a/src/components/Sidebar/OperationDialog.tsx +++ b/src/components/Sidebar/OperationDialog.tsx @@ -1,12 +1,12 @@ import { Button } from "$components/Button"; import { Dialog } from "$components/Dialog"; import { XIcon } from "$icons"; -import { clamp } from "$utils/math"; import { cn } from "$utils/tw"; -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; -import type { CSSProperties, ReactNode } from "react"; +import { useCallback, useRef } from "react"; +import type { ReactNode } from "react"; +import { type AnchorPoint, useAnchoredPosition } from "./useAnchoredPosition"; -type DialogAnchor = { x: number; y: number }; +type DialogAnchor = AnchorPoint; type OperationDialogProps = { isOpen: boolean; @@ -19,67 +19,15 @@ type OperationDialogProps = { confirmLabel: string; pendingLabel?: string; cancelLabel?: string; - confirmButtonType?: "button" | "submit"; - confirmFormId?: string; + confirm: { type: "submit"; formId: string } | { type: "action"; onConfirm: () => void }; confirmDisabled?: boolean; isPending?: boolean; - onConfirm?: () => void; tone?: "default" | "danger"; widthClassName?: string; }; type OperationHeaderProps = { title: string; description?: ReactNode; onClose: () => void; isPending: boolean }; -type OperationFooterProps = { - cancelLabel: string; - onClose: () => void; - isPending: boolean; - confirmButtonType: "button" | "submit"; - confirmFormId?: string; - onConfirm?: () => void; - tone: "default" | "danger"; - confirmClassName: string; - confirmDisabled: boolean; - confirmText: string; -}; - -const VIEWPORT_GUTTER_PX = 10; -const ANCHOR_OFFSET_PX = 12; - -function getPinnedPosition( - panelWidth: number, - panelHeight: number, - viewportWidth: number, - viewportHeight: number, - anchor?: DialogAnchor, -) { - if (!anchor) { - return { - left: Math.max(VIEWPORT_GUTTER_PX, (viewportWidth - panelWidth) / 2), - top: Math.max(VIEWPORT_GUTTER_PX, (viewportHeight - panelHeight) / 2), - }; - } - - const rightSideLeft = anchor.x + ANCHOR_OFFSET_PX; - const leftSideLeft = anchor.x - panelWidth - ANCHOR_OFFSET_PX; - const bottomTop = anchor.y + ANCHOR_OFFSET_PX; - const topTop = anchor.y - panelHeight - ANCHOR_OFFSET_PX; - - const left = rightSideLeft + panelWidth <= viewportWidth - VIEWPORT_GUTTER_PX - ? rightSideLeft - : (leftSideLeft >= VIEWPORT_GUTTER_PX - ? leftSideLeft - : clamp(rightSideLeft, VIEWPORT_GUTTER_PX, viewportWidth - panelWidth - VIEWPORT_GUTTER_PX)); - - const top = bottomTop + panelHeight <= viewportHeight - VIEWPORT_GUTTER_PX - ? bottomTop - : (topTop >= VIEWPORT_GUTTER_PX - ? topTop - : clamp(bottomTop, VIEWPORT_GUTTER_PX, viewportHeight - panelHeight - VIEWPORT_GUTTER_PX)); - - return { left, top }; -} - function OperationDialogHeader({ title, description, onClose, isPending }: OperationHeaderProps) { return (
@@ -100,37 +48,6 @@ function OperationDialogHeader({ title, description, onClose, isPending }: Opera ); } -function OperationDialogFooter( - { - cancelLabel, - onClose, - isPending, - confirmButtonType, - confirmFormId, - onConfirm, - tone, - confirmClassName, - confirmDisabled, - confirmText, - }: OperationFooterProps, -) { - return ( -
- - -
- ); -} - export function OperationDialog( { isOpen, @@ -143,18 +60,14 @@ export function OperationDialog( confirmLabel, pendingLabel, cancelLabel = "Cancel", - confirmButtonType = "button", - confirmFormId, + confirm, confirmDisabled = false, isPending = false, - onConfirm, tone = "default", widthClassName = "w-[min(92vw,380px)]", }: OperationDialogProps, ) { const panelRef = useRef(null); - const [panelStyle, setPanelStyle] = useState({}); - const [isPositioned, setIsPositioned] = useState(false); const handleRequestClose = useCallback(() => { if (!isPending) { @@ -162,77 +75,21 @@ export function OperationDialog( } }, [isPending, onClose]); - const positionPanel = useCallback(() => { - const panel = panelRef.current; - if (!panel || typeof globalThis.innerWidth !== "number") { - return; - } - - const rect = panel.getBoundingClientRect(); - const position = getPinnedPosition(rect.width, rect.height, globalThis.innerWidth, globalThis.innerHeight, anchor); - setPanelStyle({ left: `${position.left}px`, top: `${position.top}px` }); - setIsPositioned(true); - }, [anchor]); - - useLayoutEffect(() => { - if (!isOpen) { - return; - } - - setIsPositioned(false); - const frame = globalThis.requestAnimationFrame(positionPanel); - const handleResize = () => positionPanel(); - globalThis.addEventListener("resize", handleResize); - - return () => { - globalThis.cancelAnimationFrame(frame); - globalThis.removeEventListener("resize", handleResize); - }; - }, [isOpen, positionPanel]); - - useEffect(() => { - if (!isOpen) { - return; - } - - const panel = panelRef.current; - if (!panel || typeof ResizeObserver === "undefined") { - return; - } - - const observer = new ResizeObserver(positionPanel); - observer.observe(panel); - return () => observer.disconnect(); - }, [isOpen, positionPanel]); - - useEffect(() => { - if (!isOpen || typeof document.addEventListener !== "function") { - return; - } - - const handlePointerDown = (event: PointerEvent) => { - if (isPending) { - return; - } - - const panel = panelRef.current; - if (!panel) { - return; - } - - if (event.target instanceof Node && !panel.contains(event.target)) { - onClose(); - } - }; - - document.addEventListener("pointerdown", handlePointerDown); - return () => document.removeEventListener("pointerdown", handlePointerDown); - }, [isOpen, isPending, onClose]); + const { panelStyle, isPositioned } = useAnchoredPosition({ + isOpen, + anchor, + panelRef, + onRequestClose: onClose, + dismissDisabled: isPending, + }); const confirmText = isPending ? (pendingLabel ?? confirmLabel) : confirmLabel; const confirmClassName = tone === "danger" ? "border-support-error text-support-error hover:bg-support-error hover:text-white" : ""; + const confirmButtonType = confirm.type === "submit" ? "submit" : "button"; + const confirmFormId = confirm.type === "submit" ? confirm.formId : undefined; + const confirmOnClick = confirm.type === "action" ? confirm.onConfirm : undefined; return (
{children}
- +
+ + +
); diff --git a/src/components/Sidebar/RemoveButton.tsx b/src/components/Sidebar/RemoveButton.tsx deleted file mode 100644 index 9856170..0000000 --- a/src/components/Sidebar/RemoveButton.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { Button } from "$components/Button"; -import { useSkipAnimation } from "$hooks/useMotion"; -import { TrashIcon } from "$icons"; -import { AnimatePresence, motion } from "motion/react"; -import { useCallback, useMemo } from "react"; - -const MENU_INITIAL = { opacity: 0, y: -6, scale: 0.98 }; -const MENU_ANIMATE = { opacity: 1, y: 0, scale: 1 }; -const MENU_EXIT = { opacity: 0, y: -6, scale: 0.98 }; -const MENU_TRANSITION = { duration: 0.14, ease: "easeOut" as const }; -const NO_MOTION_TRANSITION = { duration: 0 }; - -export function RemoveButton( - { isMenuOpen, handleRemoveClick }: { isMenuOpen: boolean; handleRemoveClick: () => void }, -) { - const skipAnimation = useSkipAnimation(); - const transition = useMemo(() => skipAnimation ? NO_MOTION_TRANSITION : MENU_TRANSITION, [skipAnimation]); - - const Inner = useCallback( - () => ( - - ), - [handleRemoveClick], - ); - - return ( - - {isMenuOpen && ( - - - - )} - - ); -} diff --git a/src/components/Sidebar/SearchInput.tsx b/src/components/Sidebar/SearchInput.tsx index 60ea4a8..06aa609 100644 --- a/src/components/Sidebar/SearchInput.tsx +++ b/src/components/Sidebar/SearchInput.tsx @@ -1,20 +1,28 @@ import { SearchIcon } from "$icons"; +import { useSidebarState } from "$state/selectors"; +import type { ChangeEvent } from "react"; +import { useCallback } from "react"; -export const SearchInput = ( - { filterText, handleInputChange }: { - filterText: string; - handleInputChange: (e: React.ChangeEvent) => void; - }, -) => ( -
-
- - +export const SearchInput = () => { + const { filterText, setFilterText } = useSidebarState(); + + const handleInputChange = useCallback((event: ChangeEvent) => { + setFilterText(event.currentTarget.value); + }, [setFilterText]); + + return ( +
+
+ + +
-
-); + ); +}; diff --git a/src/components/Sidebar/Sidebar.tsx b/src/components/Sidebar/Sidebar.tsx index c69c1bf..2d137b3 100644 --- a/src/components/Sidebar/Sidebar.tsx +++ b/src/components/Sidebar/Sidebar.tsx @@ -1,90 +1,25 @@ import { Button } from "$components/Button"; -import { useWorkspaceController } from "$hooks/controllers/useWorkspaceController"; +import { extractClosestEdge } from "$dnd"; +import { useSidebarActions } from "$hooks/controllers/useSidebarActions"; import { useExternalDropHandler } from "$hooks/useExternalDropHandler"; import { CollapseIcon, FileAddIcon, FolderAddIcon, RefreshIcon } from "$icons"; import { useSidebarState } from "$state/selectors"; import type { DocMeta } from "$types"; -import { f } from "$utils/serialize"; -import { type Edge, extractClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"; -import { announce, cleanup as cleanupLiveRegion } from "@atlaskit/pragmatic-drag-and-drop-live-region"; -import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; -import * as logger from "@tauri-apps/plugin-log"; -import type { ChangeEventHandler, FormEvent } from "react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { AddButton } from "./AddButton"; -import { type DocumentDragData } from "./DocumentItem"; +import { + DocumentOperationDialog, + type DocumentOperationRequest, + type DocumentOperationType, +} from "./DocumentOperationDialog"; import { EmptyLocations } from "./EmptyLocations"; -import { OperationDialog } from "./OperationDialog"; import { SearchInput } from "./SearchInput"; -import { SidebarLocationItem } from "./SidebarLocationItem"; +import { SidebarLocationItem, SidebarLocationProvider } from "./SidebarLocationItem"; import { Title } from "./Title"; +import { useSidebarInternalDnD } from "./useSidebarInternalDnD"; const EMPTY_DOCUMENTS: DocMeta[] = []; - -type DestinationData = { - locationId: number; - relPath?: string; - folderPath?: string; - targetType?: "location" | "document" | "folder"; -}; - -type MoveDropDialogState = { - sourceLocationId: number; - sourceRelPath: string; - sourceTitle: string; - targetLocationId: number; -}; - -function isDocumentDragData(value: unknown): value is DocumentDragData { - if (!value || typeof value !== "object") { - return false; - } - - const maybe = value as Partial; - return maybe.type === "document" && typeof maybe.locationId === "number" && typeof maybe.relPath === "string"; -} - -function getFilename(relPath: string): string { - return relPath.split("/").pop() || relPath; -} - -function reorderDocumentsInLocation( - documents: DocMeta[], - locationId: number, - sourceRelPath: string, - destinationRelPath: string, - edge: Edge | null, -): DocMeta[] { - if (edge !== "top" && edge !== "bottom") { - return documents; - } - - const locationDocuments = documents.filter((doc) => doc.location_id === locationId); - const sourceIndex = locationDocuments.findIndex((doc) => doc.rel_path === sourceRelPath); - if (sourceIndex === -1) { - return documents; - } - - const [sourceDoc] = locationDocuments.splice(sourceIndex, 1); - const destinationIndex = locationDocuments.findIndex((doc) => doc.rel_path === destinationRelPath); - if (destinationIndex === -1) { - return documents; - } - - const insertIndex = edge === "top" ? destinationIndex : destinationIndex + 1; - locationDocuments.splice(insertIndex, 0, sourceDoc); - - let locationCursor = 0; - return documents.map((doc) => { - if (doc.location_id !== locationId) { - return doc; - } - - const next = locationDocuments[locationCursor]; - locationCursor += 1; - return next; - }); -} +const EMPTY_DIRECTORIES: string[] = []; export type SidebarProps = { onNewDocument?: (locationId?: number) => void }; @@ -92,9 +27,9 @@ type SidebarActionsProps = { onAddLocation: () => void; onAddDocument: () => void; onRefresh: () => void; - isAddDocumentDisabled: boolean; - isRefreshDisabled: boolean; onToggleCollapse: () => void; + addDocumentDisabled: boolean; + refreshDisabled: boolean; }; const HideSidebarButton = ({ onToggleCollapse }: { onToggleCollapse: () => void }) => ( @@ -111,13 +46,13 @@ const HideSidebarButton = ({ onToggleCollapse }: { onToggleCollapse: () => void ); const SidebarActions = ( - { onAddLocation, onAddDocument, onRefresh, isAddDocumentDisabled, isRefreshDisabled, onToggleCollapse }: + { onAddLocation, onAddDocument, onRefresh, onToggleCollapse, addDocumentDisabled, refreshDisabled }: SidebarActionsProps, ) => (
- - + +
); @@ -131,18 +66,20 @@ export function Sidebar({ onNewDocument }: SidebarProps) { handleRefreshSidebar, handleRenameDocument, handleMoveDocument, + handleMoveDirectory, handleDeleteDocument, - } = useWorkspaceController(); + handleImportExternalFile, + } = useSidebarActions(); const { locations, selectedLocationId, selectedDocPath, documents, + directories, isLoading, refreshingLocationId, sidebarRefreshReason, filterText, - setFilterText, setDocuments, selectLocation, toggleSidebarCollapsed, @@ -150,181 +87,36 @@ export function Sidebar({ onNewDocument }: SidebarProps) { externalDropTargetId, setExternalDropTarget, } = useSidebarState(); - const [expandedLocations, setExpandedLocations] = useState>(() => new Set(locations.map((l) => l.id))); - const [showLocationMenu, setShowLocationMenu] = useState(null); - const [moveDropDialog, setMoveDropDialog] = useState(null); - const [moveDropPath, setMoveDropPath] = useState(""); - const [isMovingDrop, setIsMovingDrop] = useState(false); - - useExternalDropHandler(selectedLocationId, documents, setExternalDropTarget, handleRefreshSidebar); - - useEffect(() => { - if (showLocationMenu === null) { - return; - } - - const handleOutsideMenuClick = (event: PointerEvent) => { - if (!(event.target instanceof HTMLElement)) { - setShowLocationMenu(null); - return; - } - - if (event.target.closest("[data-location-menu-root]")) { - return; - } - - setShowLocationMenu(null); - }; - - document.addEventListener("pointerdown", handleOutsideMenuClick); - return () => document.removeEventListener("pointerdown", handleOutsideMenuClick); - }, [showLocationMenu]); - - useEffect(() => { - const getLocationName = (locationId: number): string => - locations.find((location) => location.id === locationId)?.name ?? "location"; - - const stop = monitorForElements({ - canMonitor: ({ source }) => isDocumentDragData(source.data), - onDragStart: ({ source }) => { - if (!isDocumentDragData(source.data)) { - return; - } - announce(`Picked up ${source.data.title}`); - }, - onDropTargetChange: ({ source, location }) => { - if (!isDocumentDragData(source.data)) { - return; - } - - const destination = location.current.dropTargets[0]; - if (!destination) { - return; - } - - const destinationData = destination.data as DestinationData; - if (destinationData.folderPath) { - announce(`Over ${destinationData.folderPath} in ${getLocationName(destinationData.locationId)}`); - return; - } - - if (destinationData.targetType === "document" && destinationData.relPath) { - announce(`Over ${getFilename(destinationData.relPath)}`); - return; - } - - announce(`Over ${getLocationName(destinationData.locationId)}`); - }, - onDrop: ({ source, location }) => { - if (!isDocumentDragData(source.data)) { - return; - } - - const destination = location.current.dropTargets[0]; - if (!destination) { - announce(`Dropped ${source.data.title}`); - return; - } - - const sourceData = source.data; - const destinationData = destination.data as DestinationData; - const destinationEdge = extractClosestEdge(destinationData); - const sourceFilename = getFilename(sourceData.relPath); - const modifierDrop = location.current.input.altKey; - const resolvedTargetLocationId = destinationData.locationId; - - if (modifierDrop && resolvedTargetLocationId) { - const initialPath = destinationData.folderPath - ? `${destinationData.folderPath}/${sourceFilename}` - : (resolvedTargetLocationId === sourceData.locationId ? sourceData.relPath : sourceFilename); - - setMoveDropDialog({ - sourceLocationId: sourceData.locationId, - sourceRelPath: sourceData.relPath, - sourceTitle: sourceData.title, - targetLocationId: resolvedTargetLocationId, - }); - setMoveDropPath(initialPath); - announce(`Choose destination path for ${sourceData.title}`); - return; - } - - const refreshDocumentLists = () => { - handleRefreshSidebar(sourceData.locationId); - if (resolvedTargetLocationId && resolvedTargetLocationId !== sourceData.locationId) { - handleRefreshSidebar(resolvedTargetLocationId); - } - }; - - if (destinationData.folderPath) { - const newRelPath = `${destinationData.folderPath}/${sourceFilename}`; - void handleMoveDocument(sourceData.locationId, sourceData.relPath, newRelPath, resolvedTargetLocationId).then( - (moved) => { - if (!moved) { - announce(`Could not move ${sourceData.title}`); - return; - } - - refreshDocumentLists(); - announce(`Moved ${sourceData.title} to ${getLocationName(resolvedTargetLocationId)}`); - }, - ).catch((error: unknown) => { - logger.error( - f("Failed to move document into folder", { source: sourceData, dest: destinationData, error }), - ); - }); - return; - } - if (resolvedTargetLocationId !== sourceData.locationId) { - void handleMoveDocument(sourceData.locationId, sourceData.relPath, sourceFilename, resolvedTargetLocationId) - .then((moved) => { - if (!moved) { - announce(`Could not move ${sourceData.title}`); - return; - } - - refreshDocumentLists(); - announce(`Moved ${sourceData.title} to ${getLocationName(resolvedTargetLocationId)}`); - }).catch((error: unknown) => { - logger.error(f("Failed to move document", { source: sourceData, dest: destinationData, error })); - }); - return; - } + const [expandedLocations, setExpandedLocations] = useState>(() => new Set(locations.map((l) => l.id))); + const [documentOperation, setDocumentOperation] = useState(null); - if (destinationData.relPath && (destinationEdge === "top" || destinationEdge === "bottom")) { - setDocuments( - reorderDocumentsInLocation( - documents, - sourceData.locationId, - sourceData.relPath, - destinationData.relPath, - destinationEdge, - ), - ); - announce( - `Moved ${sourceData.title} ${destinationEdge === "top" ? "before" : "after"} ${ - getFilename(destinationData.relPath) - }`, - ); - } - }, - }); + const internalDnd = useSidebarInternalDnD({ + locations, + documents, + setDocuments, + handleMoveDocument, + handleMoveDirectory, + handleRefreshSidebar, + }); - return () => { - stop(); - cleanupLiveRegion(); - }; - }, [documents, handleMoveDocument, handleRefreshSidebar, locations, setDocuments]); + useExternalDropHandler( + selectedLocationId, + documents, + setExternalDropTarget, + handleRefreshSidebar, + handleImportExternalFile, + ); const locationDocuments = useMemo( () => (selectedLocationId ? documents.filter((doc) => doc.location_id === selectedLocationId) : []), [documents, selectedLocationId], ); + const locationDirectories = useMemo(() => (selectedLocationId ? directories : []), [directories, selectedLocationId]); const toggleLocation = useCallback((locationId: number) => { - setExpandedLocations((prev) => { - const next = new Set(prev); + setExpandedLocations((previous) => { + const next = new Set(previous); if (next.has(locationId)) { next.delete(locationId); } else { @@ -344,10 +136,14 @@ export function Sidebar({ onNewDocument }: SidebarProps) { : locationDocuments, [locationDocuments, filterText], ); + const filteredDirectories = useMemo( + () => + filterText + ? locationDirectories.filter((directoryPath) => directoryPath.toLowerCase().includes(filterText.toLowerCase())) + : locationDirectories, + [locationDirectories, filterText], + ); - const handleInputChange: ChangeEventHandler = useCallback((e) => { - setFilterText(e.currentTarget.value); - }, [setFilterText]); const handleAddDocument = useCallback(() => { if (!selectedLocationId) { return; @@ -361,60 +157,77 @@ export function Sidebar({ onNewDocument }: SidebarProps) { handleRefreshSidebar(selectedLocationId); }, [handleRefreshSidebar, selectedLocationId]); - const closeMoveDropDialog = useCallback(() => { - if (isMovingDrop) { - return; - } - setMoveDropDialog(null); - setMoveDropPath(""); - }, [isMovingDrop]); - - const handleMoveDropPathChange: ChangeEventHandler = useCallback((event) => { - setMoveDropPath(event.currentTarget.value); - }, []); - - const handleMoveDropSubmit = useCallback(async (event: FormEvent) => { - event.preventDefault(); - if (!moveDropDialog) { - return; - } + const documentActions = useMemo( + () => ({ + onSelectDocument: handleSelectDocument, + onRenameDocument: handleRenameDocument, + onMoveDocument: handleMoveDocument, + onDeleteDocument: handleDeleteDocument, + }), + [handleDeleteDocument, handleMoveDocument, handleRenameDocument, handleSelectDocument], + ); - const nextPath = moveDropPath.trim(); - if (!nextPath) { - return; - } + const openDocumentOperation = useCallback( + (type: DocumentOperationType, doc: DocMeta, anchor?: { x: number; y: number }) => { + setDocumentOperation({ type, doc, anchor }); + }, + [], + ); - setIsMovingDrop(true); - try { - const moved = await handleMoveDocument( - moveDropDialog.sourceLocationId, - moveDropDialog.sourceRelPath, - nextPath, - moveDropDialog.targetLocationId, - ); + const closeDocumentOperation = useCallback(() => { + setDocumentOperation(null); + }, []); - if (!moved) { - announce(`Could not move ${moveDropDialog.sourceTitle}`); - return; - } + const locationSharedContext = useMemo( + () => ({ filenameVisibility, documentActions, onToggleLocation: toggleLocation, openDocumentOperation }), + [documentActions, filenameVisibility, openDocumentOperation, toggleLocation], + ); - handleRefreshSidebar(moveDropDialog.sourceLocationId); - if (moveDropDialog.targetLocationId !== moveDropDialog.sourceLocationId) { - handleRefreshSidebar(moveDropDialog.targetLocationId); - } - announce(`Moved ${moveDropDialog.sourceTitle}`); - setMoveDropDialog(null); - setMoveDropPath(""); - } finally { - setIsMovingDrop(false); - } - }, [handleMoveDocument, handleRefreshSidebar, moveDropDialog, moveDropPath]); - const moveDropPathTrimmed = moveDropPath.trim(); - const isMoveDropUnchanged = moveDropDialog - ? moveDropDialog.sourceLocationId === moveDropDialog.targetLocationId - && moveDropDialog.sourceRelPath === moveDropPathTrimmed - : false; - const moveDropFormId = "sidebar-drop-move-form"; + const locationItemViewModels = useMemo(() => + locations.map((location) => { + const isSelectedLocation = selectedLocationId === location.id; + const isRefreshingLocation = refreshingLocationId === location.id; + const locationDocs = isSelectedLocation ? filteredDocuments : EMPTY_DOCUMENTS; + const locationDirs = isSelectedLocation ? filteredDirectories : EMPTY_DIRECTORIES; + const isActiveDropLocation = internalDnd.activeInternalDropTarget?.locationId === location.id; + const activeDropDocumentPath = + isActiveDropLocation && internalDnd.activeInternalDropTarget?.targetType === "document" + ? internalDnd.activeInternalDropTarget.relPath + : undefined; + const activeDropDocumentEdge = + isActiveDropLocation && internalDnd.activeInternalDropTarget?.targetType === "document" + ? extractClosestEdge(internalDnd.activeInternalDropTarget) + : null; + + return { + location, + isSelected: isSelectedLocation, + selectedDocPath, + isExpanded: expandedLocations.has(location.id), + documents: locationDocs, + directories: locationDirs, + filterText, + isRefreshing: isRefreshingLocation, + refreshReason: sidebarRefreshReason, + isExternalDropTarget: externalDropTargetId === location.id, + isInternalDropTarget: isActiveDropLocation && internalDnd.activeInternalDropTarget?.targetType === "location", + activeDropFolderPath: isActiveDropLocation ? internalDnd.activeInternalDropTarget?.folderPath : undefined, + activeDropDocumentPath, + activeDropDocumentEdge, + }; + }), [ + expandedLocations, + externalDropTargetId, + filterText, + filteredDirectories, + filteredDocuments, + internalDnd.activeInternalDropTarget, + locations, + refreshingLocationId, + selectedDocPath, + selectedLocationId, + sidebarRefreshReason, + ]); return (
- -
- {locations.length === 0 ? : locations.map((location) => { - const isSelectedLocation = selectedLocationId === location.id; - const isRefreshingLocation = refreshingLocationId === location.id; - const locationDocs = isSelectedLocation ? filteredDocuments : EMPTY_DOCUMENTS; - return ( - - ); - })} + + +
+ {locations.length === 0 + ? + : ( + + {locationItemViewModels.map((item) => ( + + ))} + + )}
@@ -468,35 +271,15 @@ export function Sidebar({ onNewDocument }: SidebarProps) { {selectedLocationId ? `${locationDocuments.length} document${locationDocuments.length === 1 ? "" : "s"}` : ""}
- -
- - -
-
+ + + {internalDnd.moveDialog} ); } diff --git a/src/components/Sidebar/SidebarLocationItem.tsx b/src/components/Sidebar/SidebarLocationItem.tsx index f3c3702..cad562a 100644 --- a/src/components/Sidebar/SidebarLocationItem.tsx +++ b/src/components/Sidebar/SidebarLocationItem.tsx @@ -1,139 +1,160 @@ import { Button } from "$components/Button"; import { ContextMenu, ContextMenuDivider, ContextMenuItem, useContextMenu } from "$components/ContextMenu"; +import { draggable, type Edge } from "$dnd"; +import type { FolderDragData } from "$dnd/sidebar"; import { useSkipAnimation } from "$hooks/useMotion"; import { FolderIcon, MoreVerticalIcon, RefreshIcon, TrashIcon } from "$icons"; import type { SidebarRefreshReason } from "$state/types"; -import { DocMeta, LocationDescriptor } from "$types"; +import type { DocMeta, LocationDescriptor } from "$types"; import { cn } from "$utils/tw"; -import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; -import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; -import type { Dispatch, MouseEventHandler, SetStateAction } from "react"; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { type DocumentDragData, DocumentItem } from "./DocumentItem"; +import type { MouseEventHandler, ReactNode } from "react"; +import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { buildDocumentTree, type DirectoryTreeNode, parentDirectoryPaths } from "./buildDocumentTree"; +import { DocumentItem } from "./DocumentItem"; +import type { DocumentOperationType } from "./DocumentOperationDialog"; import { EmptyDocuments } from "./EmptyDocuments"; -import { RemoveButton } from "./RemoveButton"; +import type { DialogAnchor } from "./OperationDialog"; import { TreeItem } from "./TreeItem"; const folderIcon = { Component: FolderIcon, size: "md" as const }; const nestedFolderIcon = { Component: FolderIcon, size: "sm" as const }; -type DirectoryTreeNode = { type: "directory"; name: string; path: string; children: TreeNode[] }; +export { canDropDocumentIntoFolder, canDropFolderIntoFolder } from "$dnd/sidebar"; -type FileTreeNode = { type: "file"; name: string; path: string; doc: DocMeta }; - -type TreeNode = DirectoryTreeNode | FileTreeNode; - -function splitPathSegments(relPath: string): string[] { - return relPath.split(/[\\/]+/).filter(Boolean); -} - -function ensureDirectoryNode(parent: DirectoryTreeNode, name: string, path: string): DirectoryTreeNode { - const existing = parent.children.find((node) => node.type === "directory" && node.path === path); - if (existing && existing.type === "directory") { - return existing; - } - - const directoryNode: DirectoryTreeNode = { type: "directory", name, path, children: [] }; - parent.children.push(directoryNode); - return directoryNode; -} - -function sortTreeNodes(children: TreeNode[]): TreeNode[] { - return children.toSorted((left, right) => { - if (left.type !== right.type) { - return left.type === "directory" ? -1 : 1; - } +export type SidebarDocumentActions = { + onSelectDocument: (id: number, path: string) => void; + onRenameDocument: (locationId: number, relPath: string, newName: string) => Promise; + onMoveDocument: (locationId: number, relPath: string, newRelPath: string) => Promise; + onDeleteDocument: (locationId: number, relPath: string) => Promise; +}; - return left.name.localeCompare(right.name, void 0, { sensitivity: "base" }); - }); -} +type SidebarLocationItemProps = { + location: LocationDescriptor; + isSelected: boolean; + selectedDocPath?: string; + isExpanded: boolean; + documents: DocMeta[]; + directories: string[]; + filterText: string; + isRefreshing: boolean; + refreshReason: SidebarRefreshReason | null; + isExternalDropTarget?: boolean; + isInternalDropTarget?: boolean; + activeDropFolderPath?: string; + activeDropDocumentPath?: string; + activeDropDocumentEdge?: Edge | null; + onRemoveLocation: (locationId: number) => void; + onSelectLocation: (locationId: number) => void; + onRefreshLocation: (locationId: number) => void; +}; -function normalizeTree(node: DirectoryTreeNode): DirectoryTreeNode { - const normalizedChildren = sortTreeNodes(node.children).map((child) => - child.type === "directory" ? normalizeTree(child) : child - ); - return { ...node, children: normalizedChildren }; -} +type SidebarLocationContextValue = { + filenameVisibility: boolean; + documentActions: SidebarDocumentActions; + onToggleLocation: (locationId: number) => void; + openDocumentOperation: (type: DocumentOperationType, doc: DocMeta, anchor?: DialogAnchor) => void; +}; -function buildDocumentTree(documents: DocMeta[]): DirectoryTreeNode { - const root: DirectoryTreeNode = { type: "directory", name: "", path: "", children: [] }; +type LocationActionProps = { handleMenuClick: MouseEventHandler }; - for (const doc of documents) { - const segments = splitPathSegments(doc.rel_path); - if (segments.length === 0) { - continue; - } +type FolderItemProps = { + name: string; + isSelected: boolean; + selectedDocPath?: string; + isExpanded: boolean; + isDropTarget?: boolean; + isRefreshing: boolean; + onItemClick: () => void; + onToggleClick: () => void; + onRefresh: () => void; + onRemove: () => void; +}; - const fileName = segments.at(-1) ?? doc.rel_path; - const parentSegments = segments.slice(0, -1); +type SidebarTreeContextValue = { + locationId: number; + selectedDocPath?: string; + filenameVisibility: boolean; + documentActions: SidebarDocumentActions; + onOpenDocumentOperation: (type: DocumentOperationType, doc: DocMeta, anchor?: DialogAnchor) => void; + dropIndicators: { + activeDropFolderPath?: string; + activeDropDocumentPath?: string; + activeDropDocumentEdge?: Edge | null; + }; +}; - let currentParent = root; - let currentPath = ""; +type NestedDirectoryItemProps = { + node: DirectoryTreeNode; + level: number; + expandedDirectories: Set; + onToggleDirectory: (path: string) => void; +}; - for (const segment of parentSegments) { - currentPath = currentPath ? `${currentPath}/${segment}` : segment; - currentParent = ensureDirectoryNode(currentParent, segment, currentPath); - } +const SidebarTreeContext = createContext(null); +const SidebarLocationContext = createContext(null); - currentParent.children.push({ type: "file", name: fileName, path: doc.rel_path, doc }); +function useSidebarTreeContext(): SidebarTreeContextValue { + const context = useContext(SidebarTreeContext); + if (!context) { + throw new Error("SidebarTreeContext is required"); } - return normalizeTree(root); + return context; } -function parentDirectoryPaths(relPath: string): string[] { - const parts = splitPathSegments(relPath); - const directories = parts.slice(0, -1); - const paths: string[] = []; - let currentPath = ""; - - for (const directory of directories) { - currentPath = currentPath ? `${currentPath}/${directory}` : directory; - paths.push(currentPath); +function useSidebarLocationContext(): SidebarLocationContextValue { + const context = useContext(SidebarLocationContext); + if (!context) { + throw new Error("SidebarLocationContext is required"); } - return paths; + return context; } -type FolderItemProps = { - name: string; - isSelected: boolean; - selectedDocPath?: string; - isExpanded: boolean; - isRefreshing: boolean; - onItemClick: () => void; - onToggleClick: () => void; - onRefresh: () => void; - actionProps: LocationActionProps; -}; +export function SidebarLocationProvider( + { value, children }: { value: SidebarLocationContextValue; children: ReactNode }, +) { + return {children}; +} function FolderItem( - { name, isSelected, selectedDocPath, isExpanded, isRefreshing, onItemClick, onToggleClick, onRefresh, actionProps }: - FolderItemProps, + { + name, + isSelected, + selectedDocPath, + isExpanded, + isDropTarget = false, + isRefreshing, + onItemClick, + onToggleClick, + onRefresh, + onRemove, + }: FolderItemProps, ) { - const { isOpen, position, open, close } = useContextMenu(); + const { isOpen, position, open, openAt, close } = useContextMenu(); - const handleContextMenu = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - open(e); + const handleContextMenu = useCallback((event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + open(event); }, [open]); + const handleMenuClick: MouseEventHandler = useCallback((event) => { + event.stopPropagation(); + const rect = event.currentTarget.getBoundingClientRect(); + openAt(rect.right, rect.bottom + 4); + }, [openAt]); + const contextMenuItems = useMemo<(ContextMenuItem | ContextMenuDivider)[]>( () => [{ label: "Refresh", onClick: onRefresh, icon: , disabled: isRefreshing }, { divider: true, - }, { - label: "Remove Location", - onClick: actionProps.handleRemoveClick, - icon: , - danger: true, - }], - [onRefresh, isRefreshing, actionProps.handleRemoveClick], + }, { label: "Remove Location", onClick: onRemove, icon: , danger: true }], + [onRefresh, isRefreshing, onRemove], ); return ( <> -
+
- +
@@ -152,13 +174,7 @@ function FolderItem( ); } -type LocationActionProps = { - isMenuOpen: boolean; - handleMenuClick: MouseEventHandler; - handleRemoveClick: () => void; -}; - -const LocationActions = ({ isMenuOpen, handleMenuClick, handleRemoveClick }: LocationActionProps) => ( +const LocationActions = ({ handleMenuClick }: LocationActionProps) => (
-
); @@ -177,82 +192,45 @@ const RefreshStatus = ({ reason }: { reason: SidebarRefreshReason | null }) => (
); -type SidebarLocationItemProps = { - location: LocationDescriptor; - isSelected: boolean; - selectedDocPath?: string; - isExpanded: boolean; - onSelect: (id: number) => void; - onToggle: (id: number) => void; - onRemove: (id: number) => void; - onRefresh: (id: number) => void; - onSelectDocument: (id: number, path: string) => void; - onRenameDocument: (locationId: number, relPath: string, newName: string) => Promise; - onMoveDocument: (locationId: number, relPath: string, newRelPath: string) => Promise; - onDeleteDocument: (locationId: number, relPath: string) => Promise; - setShowLocationMenu: Dispatch>; - documents: DocMeta[]; - isRefreshing: boolean; - refreshReason: SidebarRefreshReason | null; - filterText: string; - isMenuOpen: boolean; - filenameVisibility: boolean; - isExternalDropTarget?: boolean; -}; +function TreeDocumentNode({ doc, level }: { doc: DocMeta; level: number }) { + const { selectedDocPath, filenameVisibility, documentActions, onOpenDocumentOperation, dropIndicators } = + useSidebarTreeContext(); -type NestedDirectoryItemProps = { - node: DirectoryTreeNode; - level: number; - selectedDocPath?: string; - expandedDirectories: Set; - onToggleDirectory: (path: string) => void; - onSelectDocument: (id: number, path: string) => void; - onRenameDocument: (locationId: number, relPath: string, newName: string) => Promise; - onMoveDocument: (locationId: number, relPath: string, newRelPath: string) => Promise; - onDeleteDocument: (locationId: number, relPath: string) => Promise; - filenameVisibility: boolean; - locationId: number; -}; + return ( + + ); +} -function NestedDirectoryItem( - { - node, - level, - selectedDocPath, - expandedDirectories, - onToggleDirectory, - onSelectDocument, - onRenameDocument, - onMoveDocument, - onDeleteDocument, - filenameVisibility, - locationId, - }: NestedDirectoryItemProps, -) { +function NestedDirectoryItem({ node, level, expandedDirectories, onToggleDirectory }: NestedDirectoryItemProps) { + const { locationId, dropIndicators } = useSidebarTreeContext(); const isExpanded = expandedDirectories.has(node.path); - const folderRef = useRef(null); - const [isDropTarget, setIsDropTarget] = useState(false); + const folderRowRef = useRef(null); + const [dragState, setDragState] = useState<"idle" | "dragging">("idle"); const skipAnimation = useSkipAnimation(); + const showDropTarget = dropIndicators.activeDropFolderPath === node.path; useEffect(() => { - const element = folderRef.current; - if (!element) return; - - return combine( - dropTargetForElements({ - element, - getData: () => ({ locationId, folderPath: node.path, targetType: "folder" as const }), - canDrop: ({ source }) => { - const data = source.data as DocumentDragData; - return data.type === "document" && data.locationId === locationId - && !data.relPath.startsWith(node.path + "/"); - }, - onDragEnter: () => setIsDropTarget(true), - onDragLeave: () => setIsDropTarget(false), - onDrop: () => setIsDropTarget(false), - }), - ); - }, [locationId, node.path]); + const rowElement = folderRowRef.current; + if (!rowElement) { + return; + } + + return draggable({ + element: rowElement, + getInitialData: (): FolderDragData => ({ type: "folder", locationId, relPath: node.path, title: node.name }), + onDragStart: () => setDragState("dragging"), + onDrop: () => setDragState("idle"), + }); + }, [locationId, node.name, node.path]); const handleToggle = useCallback(() => { onToggleDirectory(node.path); @@ -260,21 +238,33 @@ function NestedDirectoryItem( return (
- + data-folder-depth={level}> +
+ +
+ {isExpanded ? (
@@ -285,30 +275,10 @@ function NestedDirectoryItem( key={child.path} node={child} level={level + 1} - selectedDocPath={selectedDocPath} expandedDirectories={expandedDirectories} - onToggleDirectory={onToggleDirectory} - onSelectDocument={onSelectDocument} - onRenameDocument={onRenameDocument} - onMoveDocument={onMoveDocument} - onDeleteDocument={onDeleteDocument} - filenameVisibility={filenameVisibility} - locationId={locationId} /> - ) - : ( - + onToggleDirectory={onToggleDirectory} /> ) + : )}
) @@ -323,77 +293,44 @@ function SidebarLocationItemComponent( isSelected, selectedDocPath, isExpanded, - onSelect, - onToggle, - onRemove, - onRefresh, - onSelectDocument, - onRenameDocument, - onMoveDocument, - onDeleteDocument, - setShowLocationMenu, documents, + directories, + filterText, isRefreshing, refreshReason, - filterText, - isMenuOpen, - filenameVisibility, - isExternalDropTarget = false, + isExternalDropTarget, + isInternalDropTarget, + activeDropFolderPath, + activeDropDocumentPath, + activeDropDocumentEdge, + onRemoveLocation, + onSelectLocation, + onRefreshLocation, }: SidebarLocationItemProps, ) { + const { filenameVisibility, documentActions, onToggleLocation, openDocumentOperation } = useSidebarLocationContext(); const [expandedDirectories, setExpandedDirectories] = useState>(new Set()); - const locationRef = useRef(null); - const [isDropTarget, setIsDropTarget] = useState(false); const skipAnimation = useSkipAnimation(); - const showHighlight = isDropTarget || isExternalDropTarget; - - useEffect(() => { - const element = locationRef.current; - if (!element) return; - - return combine( - dropTargetForElements({ - element, - getData: () => ({ locationId: location.id, targetType: "location" as const }), - canDrop: ({ source }) => { - const data = source.data as DocumentDragData; - return data.type === "document"; - }, - onDragEnter: () => setIsDropTarget(true), - onDragLeave: () => setIsDropTarget(false), - onDrop: () => setIsDropTarget(false), - }), - ); - }, [location.id]); + const showHighlight = Boolean(isExternalDropTarget) || Boolean(isInternalDropTarget); + const showRootDropIndicator = Boolean(isInternalDropTarget) && !activeDropFolderPath && !activeDropDocumentPath; const handleRemoveClick = useCallback(() => { - onRemove(location.id); - setShowLocationMenu(null); - }, [location.id, onRemove, setShowLocationMenu]); - - const handleMenuClick: MouseEventHandler = useCallback((event) => { - event.stopPropagation(); - setShowLocationMenu((current) => current === location.id ? null : location.id); - }, [location.id, setShowLocationMenu]); + onRemoveLocation(location.id); + }, [location.id, onRemoveLocation]); const handleRefresh = useCallback(() => { - onRefresh(location.id); - }, [location.id, onRefresh]); + onRefreshLocation(location.id); + }, [location.id, onRefreshLocation]); const onItemClick = useCallback(() => { - onSelect(location.id); - }, [location.id, onSelect]); + onSelectLocation(location.id); + }, [location.id, onSelectLocation]); const onToggleClick = useCallback(() => { - onToggle(location.id); - }, [location.id, onToggle]); + onToggleLocation(location.id); + }, [location.id, onToggleLocation]); - const actionProps = useMemo(() => ({ isMenuOpen, handleMenuClick, handleRemoveClick }), [ - isMenuOpen, - handleMenuClick, - handleRemoveClick, - ]); - const documentTree = useMemo(() => buildDocumentTree(documents), [documents]); + const documentTree = useMemo(() => buildDocumentTree(documents, directories), [documents, directories]); useEffect(() => { if (!selectedDocPath) { @@ -430,64 +367,88 @@ function SidebarLocationItemComponent( }); }, []); + const treeContextValue = useMemo( + () => ({ + locationId: location.id, + selectedDocPath, + filenameVisibility, + documentActions: { + onSelectDocument: documentActions.onSelectDocument, + onRenameDocument: documentActions.onRenameDocument, + onMoveDocument: documentActions.onMoveDocument, + onDeleteDocument: documentActions.onDeleteDocument, + }, + onOpenDocumentOperation: openDocumentOperation, + dropIndicators: { activeDropFolderPath, activeDropDocumentPath, activeDropDocumentEdge }, + }), + [ + activeDropDocumentEdge, + activeDropDocumentPath, + activeDropFolderPath, + documentActions, + filenameVisibility, + location.id, + openDocumentOperation, + selectedDocPath, + ], + ); + + const renderedTreeNodes = useMemo( + () => + documentTree.children.map((node) => + node.type === "directory" + ? ( + + ) + : + ), + [documentTree.children, expandedDirectories, handleToggleDirectory], + ); + return (
+ onRemove={handleRemoveClick} /> {isExpanded && isSelected && (
+
+ {isRefreshing ? : null} - {documents.length === 0 + + {documentTree.children.length === 0 ? : ( -
- {documentTree.children.map((node) => - node.type === "directory" - ? ( - - ) - : ( - - ) - )} -
+ +
{renderedTreeNodes}
+
)}
)} diff --git a/src/components/Sidebar/TreeItem.tsx b/src/components/Sidebar/TreeItem.tsx index 7dd7d27..8da4e4f 100644 --- a/src/components/Sidebar/TreeItem.tsx +++ b/src/components/Sidebar/TreeItem.tsx @@ -39,18 +39,6 @@ function TreeItemComponent( ) { const paddingLeft = level * 16 + 12; - const handleMouseEnter: MouseEventHandler = useCallback((e) => { - if (!isSelected) { - (e.currentTarget as HTMLDivElement).classList.add("bg-layer-hover-01"); - } - }, [isSelected]); - - const handleMouseLeave: MouseEventHandler = useCallback((e) => { - if (!isSelected) { - (e.currentTarget as HTMLDivElement).classList.remove("bg-layer-hover-01"); - } - }, [isSelected]); - const handleButtonClick: MouseEventHandler = useCallback((e) => { e.stopPropagation(); onToggle?.(); @@ -64,7 +52,8 @@ function TreeItemComponent( const containerClasses = useMemo(() => { const base = [ "sidebar-item group flex items-center gap-2", - "cursor-pointer rounded mx-2 mb-0.5 text-[0.8125rem]", + "cursor-pointer rounded mx-2 text-[0.8125rem]", + isSelected ? "sidebar-item--selected" : "sidebar-item--unselected", isDragging || isDropTarget ? "" : "transition-colors duration-150", ]; @@ -73,7 +62,7 @@ function TreeItemComponent( } if (isDropTarget) { - base.push("ring-2 ring-border-interactive"); + base.push("ring-2 ring-border-interactive !bg-layer-hover-01 text-text-primary sidebar-drop-pulse"); } if (isSelected) { @@ -92,14 +81,7 @@ function TreeItemComponent( const labelStyle: CSSProperties = useMemo(() => ({ fontWeight: isSelected ? 500 : 400 }), [isSelected]); return ( -
+
{hasChildItems ? ( diff --git a/src/components/Toolbar/ToolbarButton.tsx b/src/components/Toolbar/ToolbarButton.tsx index f472f41..17cc6b2 100644 --- a/src/components/Toolbar/ToolbarButton.tsx +++ b/src/components/Toolbar/ToolbarButton.tsx @@ -45,7 +45,7 @@ export function ToolbarButton( disabled={disabled} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} - className={`flex items-center gap-1.5 px-2.5 py-1.5 text-[0.8125rem] relative transition-all duration-150 ease rounded ${ + className={`flex items-center gap-1.5 px-2.5 py-1.5 text-[0.8125rem] relative transition-colors duration-150 ease rounded ${ isActive ? "bg-layer-accent-01 border border-border-strong text-text-primary" : "bg-transparent border border-transparent text-text-secondary" diff --git a/src/components/export/ExportDialog/ExportDialog.tsx b/src/components/export/ExportDialog/ExportDialog.tsx index db57a48..da2b8e7 100644 --- a/src/components/export/ExportDialog/ExportDialog.tsx +++ b/src/components/export/ExportDialog/ExportDialog.tsx @@ -19,7 +19,9 @@ import { useWorkspaceDocumentsState, } from "$state/selectors"; import type { EditorFontFamily, ExportFormat } from "$types"; -import { type MouseEvent, useCallback, useMemo, useState } from "react"; +import { f } from "$utils/serialize"; +import * as logger from "@tauri-apps/plugin-log"; +import { type MouseEvent, useCallback, useEffect, useMemo, useState } from "react"; import { ExportDialogFooter, PdfExportDialogFooter } from "./ExportFooter"; import { ExportDialogHeader } from "./ExportHeader"; import { PdfExportDialogOptions } from "./ExportOptions"; @@ -90,7 +92,7 @@ function ExportError({ error }: { error: string | null }) { } const FormatSummary = ({ title, description }: { title: string; description: string }) => ( -
+

{title}

{description}

@@ -103,13 +105,13 @@ type PreviewPaneProps = { }; const PreviewPane = ({ previewResult, options, editorFontFamily }: PreviewPaneProps) => ( -
+
); const OptionsPane = ({ isFullWidth }: { isFullWidth: boolean }) => ( -
+
); @@ -129,19 +131,22 @@ function PdfExportContent( const { pdfExportError: error } = usePdfExportState(); return ( - <> +
-
+
{showPreview ? : null}
- +
); } @@ -187,12 +192,15 @@ function TextExportContent( const { textExportError: error, isExportingText } = useTextExportState(); return ( - <> +
-
+
{showPreview ? (
@@ -207,7 +215,7 @@ function TextExportContent( onCancel={onCancel} label="Export Text" isLoading={isExportingText} /> - +
); } @@ -217,12 +225,12 @@ function DocxExportContent({ onCancel, handleDocxExport }: DocxExportContentProp const { docxExportError: error, isExportingDocx } = useDocxExportState(); return ( - <> +
-
+

Included formatting

Headings, emphasis, code, lists, and blockquotes are retained. @@ -238,7 +246,7 @@ function DocxExportContent({ onCancel, handleDocxExport }: DocxExportContentProp onCancel={onCancel} label="Export DOCX" isLoading={isExportingDocx} /> - +

); } @@ -305,11 +313,19 @@ export function ExportDialog({ onExport, previewResult, editorFontFamily, docume }, [handleCancel, handleExportDocx]); const compactPanel = useMemo(() => isCompact || viewportWidth < 1024, [isCompact, viewportWidth]); - const showPreview = useMemo(() => !compactPanel && viewportWidth >= 1200, [compactPanel, viewportWidth]); + const showPreview = useMemo(() => !compactPanel && viewportWidth >= 1280, [compactPanel, viewportWidth]); const isPdfTabActive = useMemo(() => activeExportTabId === "pdf", [activeExportTabId]); const isDocxTabActive = useMemo(() => activeExportTabId === "docx", [activeExportTabId]); const isTextTabActive = useMemo(() => activeExportTabId === "txt", [activeExportTabId]); + useEffect(() => { + if (!isOpen) { + return; + } + + logger.debug(f("Export dialog layout resolved", { viewportWidth, compactPanel, showPreview, activeExportTabId })); + }, [activeExportTabId, compactPanel, isOpen, showPreview, viewportWidth]); + const containerClasses = useMemo(() => { if (compactPanel) { return "z-50 flex pointer-events-none items-end justify-center px-3 pb-3"; @@ -319,12 +335,12 @@ export function ExportDialog({ onExport, previewResult, editorFontFamily, docume const panelClasses = useMemo(() => { if (compactPanel) { - return "pointer-events-auto flex w-full max-w-[980px] max-h-[calc(100vh-4.25rem)] flex-col rounded-xl border border-border-subtle bg-layer-01 shadow-2xl"; + return "pointer-events-auto flex h-[min(90vh,760px)] w-full max-w-[980px] flex-col rounded-xl border border-border-subtle bg-layer-01 shadow-2xl"; } return showPreview - ? "pointer-events-auto flex w-[min(88vw,1120px)] max-h-[85vh] flex-col rounded-xl border border-border-subtle bg-layer-01 shadow-2xl" - : "pointer-events-auto flex w-full max-w-3xl max-h-[85vh] flex-col rounded-xl border border-border-subtle bg-layer-01 shadow-2xl"; + ? "pointer-events-auto flex h-[min(86vh,820px)] w-[min(84vw,1020px)] flex-col rounded-xl border border-border-subtle bg-layer-01 shadow-2xl" + : "pointer-events-auto flex h-[min(82vh,720px)] w-[min(84vw,760px)] flex-col rounded-xl border border-border-subtle bg-layer-01 shadow-2xl"; }, [compactPanel, showPreview]); const handleExportFormatTabClick = useCallback((event: MouseEvent) => { @@ -355,24 +371,26 @@ export function ExportDialog({ onExport, previewResult, editorFontFamily, docume backdropClassName="bg-black/40" containerClassName={containerClasses} panelClassName={panelClasses}> -
+
- {isPdfTabActive && ( - - )} - {isDocxTabActive && } - {isTextTabActive && activeTab && ( - - )} - {isTextTabActive && !activeTab ? : null} +
+ {isPdfTabActive && ( + + )} + {isDocxTabActive && } + {isTextTabActive && activeTab && ( + + )} + {isTextTabActive && !activeTab ? : null} +
); diff --git a/src/components/export/preview/PdfPreview.tsx b/src/components/export/preview/PdfPreview.tsx index aac1a65..546875d 100644 --- a/src/components/export/preview/PdfPreview.tsx +++ b/src/components/export/preview/PdfPreview.tsx @@ -1,10 +1,14 @@ import { MarkdownPdfDocument } from "$components/export/MarkdownPdfDocument"; +import { PDFError } from "$pdf/errors"; import { ensurePdfFontRegistered } from "$pdf/fonts"; import type { FontStrategy, PdfExportOptions, PdfRenderResult } from "$pdf/types"; import type { EditorFontFamily } from "$types"; +import { f } from "$utils/serialize"; import { pdf } from "@react-pdf/renderer"; +import * as logger from "@tauri-apps/plugin-log"; import * as pdfjsLib from "pdfjs-dist"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { ChangeEvent, MouseEvent } from "react"; pdfjsLib.GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/build/pdf.worker.mjs", import.meta.url).href; @@ -12,6 +16,7 @@ type PdfPreviewState = { status: "idle" } | { status: "loading" } | { status: "e status: "success"; pdfDoc: pdfjsLib.PDFDocumentProxy; pageCount: number; + usedBuiltinFonts: boolean; }; type UsePdfPreviewArgs = { @@ -26,11 +31,34 @@ export type PdfPreviewPanelProps = { editorFontFamily: EditorFontFamily; }; +type FitMode = "page" | "width"; +type ZoomDirection = "in" | "out"; + const MAX_RETRIES = 2; +const MIN_ZOOM = 0.6; +const MAX_ZOOM = 2.5; +const ZOOM_STEP = 0.1; +const FIT_MODE_OPTIONS: Array<{ value: FitMode; label: string }> = [{ value: "width", label: "Fit Width" }, { + value: "page", + label: "Fit Page", +}]; + +const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); + +const getErrorMessage = (error: unknown) => error instanceof Error ? error.message : "Failed to generate preview"; export function usePdfPreview({ result, options, editorFontFamily }: UsePdfPreviewArgs) { const [state, setState] = useState({ status: "idle" }); const abortControllerRef = useRef(null); + const currentPdfDocRef = useRef(null); + + const destroyCurrentPdfDoc = useCallback(() => { + const currentDoc = currentPdfDocRef.current; + if (currentDoc) { + currentDoc.destroy(); + currentPdfDocRef.current = null; + } + }, []); const renderPdfBlob = useCallback( async ( @@ -44,6 +72,8 @@ export function usePdfPreview({ result, options, editorFontFamily }: UsePdfPrevi throw new Error("Preview generation aborted"); } + logger.debug(f("PDF preview render attempt started", { strategy, fontFamily })); + await ensurePdfFontRegistered(fontFamily, strategy); await ensurePdfFontRegistered("IBM Plex Mono", strategy); @@ -60,6 +90,8 @@ export function usePdfPreview({ result, options, editorFontFamily }: UsePdfPrevi useBuiltinFonts={strategy === "builtin"} />, ).toBlob(); + logger.debug(f("PDF preview render attempt completed", { strategy, outputSizeBytes: blob.size })); + return blob; }, [], @@ -67,6 +99,7 @@ export function usePdfPreview({ result, options, editorFontFamily }: UsePdfPrevi const generatePreview = useCallback(async (signal: AbortSignal) => { if (!result) { + destroyCurrentPdfDoc(); setState({ status: "idle" }); return; } @@ -74,36 +107,53 @@ export function usePdfPreview({ result, options, editorFontFamily }: UsePdfPrevi setState({ status: "loading" }); try { - let blob: Blob; + let blob: Blob | null = null; + let strategyUsed: FontStrategy = "custom"; + let customError: unknown = null; - for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + for (let attempt = 0; attempt < MAX_RETRIES; attempt += 1) { const strategy: FontStrategy = attempt === 0 ? "custom" : "builtin"; try { - if (signal.aborted) { - throw new Error("Preview generation aborted"); - } - blob = await renderPdfBlob(result, options, editorFontFamily, strategy, signal); + strategyUsed = strategy; break; } catch (error) { if (signal.aborted) { throw error; } - if (attempt < MAX_RETRIES - 1) { + if (attempt === 0) { + customError = error; + logger.warn( + f("PDF preview custom font render failed; retrying with built-in fonts", { + editorFontFamily, + error: PDFError.serialize(error), + }), + ); continue; } + logger.error( + f("PDF preview render failed", { + editorFontFamily, + customError: PDFError.serialize(customError), + builtinError: PDFError.serialize(error), + }), + ); throw error; } } + if (!blob) { + throw new Error("Failed to build preview blob"); + } + if (signal.aborted) { throw new Error("Preview generation aborted"); } - const arrayBuffer = await blob!.arrayBuffer(); + const arrayBuffer = await blob.arrayBuffer(); const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer }); const pdfDoc = await loadingTask.promise; @@ -112,16 +162,18 @@ export function usePdfPreview({ result, options, editorFontFamily }: UsePdfPrevi throw new Error("Preview generation aborted"); } - setState({ status: "success", pdfDoc, pageCount: pdfDoc.numPages }); + destroyCurrentPdfDoc(); + currentPdfDocRef.current = pdfDoc; + setState({ status: "success", pdfDoc, pageCount: pdfDoc.numPages, usedBuiltinFonts: strategyUsed === "builtin" }); } catch (error) { if (signal.aborted) { return; } - const message = error instanceof Error ? error.message : "Failed to generate preview"; - setState({ status: "error", message }); + destroyCurrentPdfDoc(); + setState({ status: "error", message: getErrorMessage(error) }); } - }, [result, options, editorFontFamily, renderPdfBlob]); + }, [destroyCurrentPdfDoc, editorFontFamily, options, renderPdfBlob, result]); useEffect(() => { abortControllerRef.current?.abort(); @@ -141,127 +193,69 @@ export function usePdfPreview({ result, options, editorFontFamily }: UsePdfPrevi useEffect(() => { return () => { - if (state.status === "success") { - state.pdfDoc.destroy(); - } + abortControllerRef.current?.abort(); + destroyCurrentPdfDoc(); }; - }, [state]); + }, [destroyCurrentPdfDoc]); return state; } -const PreviewSkeletonLines = () => ( - <> -
-
-
-
-
-
-
-
-
-
-
- -); - const PreviewSkeleton = () => ( -
-
-
- -
+
+
+
+
+
+
); const PreviewError = ({ message }: { message: string }) => ( -
+
-

Failed to generate preview

-

{message}

+

Failed to generate preview

+

{message}

); -type PageNavigationProps = { currentPage: number; pageCount: number; onPrev: () => void; onNext: () => void }; - -const PageNavigation = ({ currentPage, pageCount, onPrev, onNext }: PageNavigationProps) => ( -
- - {currentPage} / {pageCount} - -
-); - -type PdfPageCanvasProps = { pdfDoc: pdfjsLib.PDFDocumentProxy; pageNumber: number }; -type FitMode = "page" | "width"; -type ZoomDirection = "in" | "out"; -type ZoomButtonProps = { - direction: ZoomDirection; - disabled: boolean; - onClick: (event: React.MouseEvent) => void; +type MultiPageCanvasProps = { + pdfDoc: pdfjsLib.PDFDocumentProxy; + pageCount: number; + fitMode: FitMode; + zoomLevel: number; + scrollToPage: number; + onVisiblePageChange: (page: number) => void; }; -const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); -const MIN_ZOOM = 0.5; -const MAX_ZOOM = 2.5; -const ZOOM_STEP = 0.1; -const FIT_MODE_OPTIONS: Array<{ value: FitMode; label: string }> = [{ value: "page", label: "Fit Page" }, { - value: "width", - label: "Fit Width", -}]; - -const FitModeSelect = ( - { fitMode, onChange }: { fitMode: FitMode; onChange: (event: React.ChangeEvent) => void }, -) => ( - -); - -const ZoomButton = ({ direction, disabled, onClick }: ZoomButtonProps) => ( - -); - -const PdfPageCanvas = ( - { pdfDoc, pageNumber, fitMode, zoomLevel }: PdfPageCanvasProps & { fitMode: FitMode; zoomLevel: number }, -) => { - const canvasRef = useRef(null); +function MultiPageCanvas( + { pdfDoc, pageCount, fitMode, zoomLevel, scrollToPage, onVisiblePageChange }: MultiPageCanvasProps, +) { const containerRef = useRef(null); - const renderTaskRef = useRef(null); - const [containerSize, setContainerSize] = useState<{ width: number; height: number }>({ width: 0, height: 0 }); + const pageContainerRefs = useRef>([]); + const canvasRefs = useRef>([]); + const renderTasksRef = useRef>(new Map()); + const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); + + const pageNumbers = useMemo(() => Array.from({ length: pageCount }, (_, index) => index + 1), [pageCount]); + const pageContainerRefSetters = useMemo(() => + pageNumbers.map((_, index) => (element: HTMLDivElement | null) => { + pageContainerRefs.current[index] = element; + }), [pageNumbers]); + const canvasRefSetters = useMemo(() => + pageNumbers.map((_, index) => (element: HTMLCanvasElement | null) => { + canvasRefs.current[index] = element; + }), [pageNumbers]); + + useEffect(() => { + pageContainerRefs.current = Array.from( + { length: pageCount }, + (_, index) => pageContainerRefs.current[index] ?? null, + ); + canvasRefs.current = Array.from({ length: pageCount }, (_, index) => canvasRefs.current[index] ?? null); + }, [pageCount]); useEffect(() => { const container = containerRef.current; @@ -274,9 +268,12 @@ const PdfPageCanvas = ( if (!entry) { return; } + const width = Math.max(1, Math.floor(entry.contentRect.width)); const height = Math.max(1, Math.floor(entry.contentRect.height)); - setContainerSize((prev) => (prev.width === width && prev.height === height ? prev : { width, height })); + setContainerSize((previous) => + previous.width === width && previous.height === height ? previous : { width, height } + ); }); observer.observe(container); @@ -286,190 +283,330 @@ const PdfPageCanvas = ( }, []); useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; + let cancelled = false; + const tasks = renderTasksRef.current; - const ctx = canvas.getContext("2d"); - if (!ctx) return; + const cancelTasks = () => { + for (const task of tasks.values()) { + task.cancel(); + } + tasks.clear(); + }; - let cancelled = false; + const renderPage = async (pageNumber: number) => { + const canvas = canvasRefs.current[pageNumber - 1]; + if (!canvas) { + return; + } + + const context = canvas.getContext("2d"); + if (!context) { + return; + } - const renderPage = async () => { try { const page = await pdfDoc.getPage(pageNumber); + if (cancelled) { page.cleanup(); return; } - const measuredWidth = containerSize.width > 0 - ? containerSize.width - : (canvas.parentElement?.clientWidth ?? 600); - const measuredHeight = containerSize.height > 0 - ? containerSize.height - : (canvas.parentElement?.clientHeight ?? 800); - const viewport = page.getViewport({ scale: 1 }); - const fitScale = fitMode === "width" - ? measuredWidth / viewport.width - : Math.min(measuredWidth / viewport.width, measuredHeight / viewport.height); - const scale = clamp(fitScale * zoomLevel, 0.1, 4); - const scaledViewport = page.getViewport({ scale }); - - canvas.width = scaledViewport.width; - canvas.height = scaledViewport.height; - - if (renderTaskRef.current) { - renderTaskRef.current.cancel(); + const baseViewport = page.getViewport({ scale: 1 }); + const effectiveWidth = Math.max(1, containerSize.width - 24); + const effectiveHeight = Math.max(1, containerSize.height - 24); + const widthScale = effectiveWidth / baseViewport.width; + const heightScale = effectiveHeight / baseViewport.height; + const fitScale = fitMode === "width" ? widthScale : Math.min(widthScale, heightScale); + const previewScale = clamp(fitScale * zoomLevel, 0.1, 4); + const viewport = page.getViewport({ scale: previewScale }); + const devicePixelRatio = Math.max(1, globalThis.devicePixelRatio || 1); + const renderViewport = page.getViewport({ scale: previewScale * devicePixelRatio }); + + canvas.width = Math.floor(renderViewport.width); + canvas.height = Math.floor(renderViewport.height); + canvas.style.width = `${Math.floor(viewport.width)}px`; + canvas.style.height = `${Math.floor(viewport.height)}px`; + + const existingTask = tasks.get(pageNumber); + existingTask?.cancel(); + + const renderTask = page.render({ canvas, canvasContext: context, viewport: renderViewport }); + tasks.set(pageNumber, renderTask); + + await renderTask.promise; + tasks.delete(pageNumber); + page.cleanup(); + } catch (error) { + if (!(error instanceof Error && error.message.includes("cancelled"))) { + logger.debug(f("PDF preview page render failed", { pageNumber, message: getErrorMessage(error) })); } + } + }; - renderTaskRef.current = page.render({ canvasContext: ctx, viewport: scaledViewport, canvas }); + void Promise.all(pageNumbers.map((pageNumber) => renderPage(pageNumber))); - await renderTaskRef.current.promise; - page.cleanup(); - } catch (err) { - if (err instanceof Error && err.message.includes("cancelled")) { - return void 0; + return () => { + cancelled = true; + cancelTasks(); + }; + }, [containerSize.height, containerSize.width, fitMode, pageNumbers, pdfDoc, zoomLevel]); + + useEffect(() => { + const target = pageContainerRefs.current[scrollToPage - 1]; + if (!target) { + return; + } + + if (typeof target.scrollIntoView === "function") { + target.scrollIntoView({ block: "start", behavior: "smooth" }); + } + }, [scrollToPage]); + + useEffect(() => { + const container = containerRef.current; + if (!container) { + return; + } + + let animationFrame = 0; + + const updateVisiblePage = () => { + const containerRect = container.getBoundingClientRect(); + const viewportMiddleY = containerRect.top + (containerRect.height / 2); + + let closestPage = 1; + let closestDistance = Number.POSITIVE_INFINITY; + + for (let index = 0; index < pageContainerRefs.current.length; index += 1) { + const pageElement = pageContainerRefs.current[index]; + if (!pageElement) { + continue; + } + + const pageRect = pageElement.getBoundingClientRect(); + const pageMiddleY = pageRect.top + (pageRect.height / 2); + const distance = Math.abs(pageMiddleY - viewportMiddleY); + + if (distance < closestDistance) { + closestDistance = distance; + closestPage = index + 1; } } + + onVisiblePageChange(closestPage); + animationFrame = 0; }; - void renderPage(); + const scheduleVisiblePageUpdate = () => { + if (animationFrame !== 0) { + return; + } + + animationFrame = globalThis.requestAnimationFrame(updateVisiblePage); + }; + + scheduleVisiblePageUpdate(); + container.addEventListener("scroll", scheduleVisiblePageUpdate, { passive: true }); return () => { - cancelled = true; - if (renderTaskRef.current) { - renderTaskRef.current.cancel(); + container.removeEventListener("scroll", scheduleVisiblePageUpdate); + if (animationFrame !== 0) { + globalThis.cancelAnimationFrame(animationFrame); } }; - }, [containerSize.height, containerSize.width, fitMode, pdfDoc, pageNumber, zoomLevel]); + }, [containerSize.height, containerSize.width, onVisiblePageChange, pageCount]); return ( -
-
- +
+
+ {pageNumbers.map((pageNumber, index) => ( +
+ +
+ ))}
); -}; +} type PreviewToolbarProps = { fitMode: FitMode; zoomLevel: number; - handleFitModeChange: (event: React.ChangeEvent) => void; - handleZoomClick: (event: React.MouseEvent) => void; + currentPage: number; + pageCount: number; + onFitModeChange: (event: ChangeEvent) => void; + onZoomClick: (event: MouseEvent) => void; + onPageInputChange: (event: ChangeEvent) => void; + onPrev: () => void; + onNext: () => void; }; -function PreviewToolbar({ fitMode, zoomLevel, handleFitModeChange, handleZoomClick }: PreviewToolbarProps) { - const zoomPercent = useMemo(() => Math.round(zoomLevel * 100), [zoomLevel]); +function PreviewToolbar( + { fitMode, zoomLevel, currentPage, pageCount, onFitModeChange, onZoomClick, onPageInputChange, onPrev, onNext }: + PreviewToolbarProps, +) { + const zoomPercent = Math.round(zoomLevel * 100); return ( -
- - - - - {zoomPercent}% - - = MAX_ZOOM} /> -
- ); -} - -type PreviewNavigationInputProps = { - pageCount: number; - currentPage: number; - handlePageInputChange: (event: React.ChangeEvent) => void; -}; +
+
+ + + + + + {zoomPercent}% + + +
-function PreviewNavigationInput({ pageCount, currentPage, handlePageInputChange }: PreviewNavigationInputProps) { - return ( -
- - - / {pageCount} + {pageCount > 1 + ? ( +
+ + + + {currentPage} / {pageCount} + +
+ ) + : {currentPage} / {pageCount}}
); } -type PreviewSuccessProps = { pdfDoc: pdfjsLib.PDFDocumentProxy; pageCount: number }; +type PreviewSuccessProps = { pdfDoc: pdfjsLib.PDFDocumentProxy; pageCount: number; usedBuiltinFonts: boolean }; -function PreviewSuccess({ pdfDoc, pageCount }: PreviewSuccessProps) { +function PreviewSuccess({ pdfDoc, pageCount, usedBuiltinFonts }: PreviewSuccessProps) { const [currentPage, setCurrentPage] = useState(1); - const [fitMode, setFitMode] = useState("page"); + const [fitMode, setFitMode] = useState("width"); const [zoomLevel, setZoomLevel] = useState(1); + const [scrollToPage, setScrollToPage] = useState(1); useEffect(() => { setCurrentPage(1); - setFitMode("page"); + setScrollToPage(1); + setFitMode("width"); setZoomLevel(1); }, [pdfDoc]); - useEffect(() => { - setCurrentPage((prev) => Math.min(prev, pageCount)); + const goToPage = useCallback((page: number) => { + const nextPage = clamp(page, 1, pageCount); + setCurrentPage(nextPage); + setScrollToPage(nextPage); }, [pageCount]); - const handlePrev = useCallback(() => { - setCurrentPage((prev) => Math.max(1, prev - 1)); + const handleFitModeChange = useCallback((event: ChangeEvent) => { + setFitMode(event.target.value as FitMode); }, []); - const handleNext = useCallback(() => { - setCurrentPage((prev) => Math.min(pageCount, prev + 1)); - }, [pageCount]); - const handleZoom = useCallback((direction: ZoomDirection) => { - setZoomLevel((prev) => { - const next = direction === "in" ? prev + ZOOM_STEP : prev - ZOOM_STEP; + setZoomLevel((previous) => { + const next = direction === "in" ? previous + ZOOM_STEP : previous - ZOOM_STEP; return clamp(Math.round(next * 10) / 10, MIN_ZOOM, MAX_ZOOM); }); }, []); - const handleZoomClick = useCallback((event: React.MouseEvent) => { + const handleZoomClick = useCallback((event: MouseEvent) => { const direction = event.currentTarget.dataset.zoomDirection as ZoomDirection | undefined; if (!direction) { return; } + handleZoom(direction); }, [handleZoom]); - const handleFitModeChange = useCallback((event: React.ChangeEvent) => { - setFitMode(event.target.value as FitMode); - setZoomLevel(1); - }, []); - - const handlePageInputChange = useCallback((event: React.ChangeEvent) => { + const handlePageInputChange = useCallback((event: ChangeEvent) => { const nextPage = Number.parseInt(event.target.value, 10); if (Number.isNaN(nextPage)) { return; } - setCurrentPage(clamp(nextPage, 1, pageCount)); - }, [pageCount]); + + goToPage(nextPage); + }, [goToPage]); + + const handlePrev = useCallback(() => { + goToPage(currentPage - 1); + }, [currentPage, goToPage]); + + const handleNext = useCallback(() => { + goToPage(currentPage + 1); + }, [currentPage, goToPage]); return ( -
-
- - -
-
- -
- {pageCount > 1 && ( - - )} +
+ + {usedBuiltinFonts + ? ( +
+ Preview is using built-in fonts due to custom font loading issues. +
+ ) + : null} +
); } @@ -479,7 +616,7 @@ export function PdfPreviewPanel({ result, options, editorFontFamily }: PdfPrevie if (previewState.status === "idle") { return ( -
+

Select a document to preview

); @@ -493,5 +630,10 @@ export function PdfPreviewPanel({ result, options, editorFontFamily }: PdfPrevie return ; } - return ; + return ( + + ); } diff --git a/src/dnd/index.ts b/src/dnd/index.ts new file mode 100644 index 0000000..36fb678 --- /dev/null +++ b/src/dnd/index.ts @@ -0,0 +1,559 @@ +export type Edge = "top" | "bottom"; + +type DragInput = { clientX: number; clientY: number; x: number; y: number; altKey: boolean }; + +type DragSource = { data: unknown; element: HTMLElement }; + +type DropTarget = { element: HTMLElement; data: unknown }; + +type DragLocation = { dropTargets: DropTarget[]; input: DragInput }; + +type DraggableArgs = { + element: HTMLElement; + getInitialData: () => unknown; + onDragStart?: () => void; + onDrop?: () => void; +}; + +type DropTargetArgs = { + element: HTMLElement; + canDrop: (args: { source: DragSource }) => boolean; + getData: (args: { source: DragSource; input: DragInput }) => unknown; +}; + +type MonitorArgs = { + canMonitor?: (args: { source: DragSource }) => boolean; + onDragStart?: (args: { source: DragSource }) => void; + onDropTargetChange?: ( + args: { source: DragSource; location: { current: DragLocation; previous: DragLocation } }, + ) => void; + onDrop?: (args: { source: DragSource; location: { current: DragLocation; previous: DragLocation } }) => void; +}; + +type ActiveDrag = { + source: DragSource; + sourceOnDrop?: () => void; + currentLocation: DragLocation; + previousLocation: DragLocation; +}; + +type LiveRegionState = { node: HTMLElement | null; timer: ReturnType | null }; + +export type DestinationData = { + locationId: number; + relPath?: string; + folderPath?: string; + targetType?: "location" | "document" | "folder"; +}; + +const INTERNAL_MIME = "application/x-writer-sidebar-dnd"; +const EDGE_KEY = "__writerClosestEdge"; +const LIVE_REGION_ID = "writer-dnd-live-region"; + +const monitors = new Set(); +let activeDrag: ActiveDrag | null = null; +let lastKnownPoint: { x: number; y: number } | null = null; + +const liveRegionState: LiveRegionState = { node: null, timer: null }; + +function makeEmptyLocation(input: DragInput): DragLocation { + return { dropTargets: [], input }; +} + +function keyOfDropTarget(target: DropTarget): string { + const data = target.data; + if (!data || typeof data !== "object") { + return `${String(data)}`; + } + + const maybe = data as Partial; + const edge = maybe[EDGE_KEY] ?? "none"; + return `${maybe.locationId ?? "none"}|${maybe.targetType ?? "unknown"}|${maybe.folderPath ?? ""}|${ + maybe.relPath ?? "" + }|${edge}`; +} + +function areDropTargetsEqual(left: DropTarget[], right: DropTarget[]): boolean { + if (left.length !== right.length) { + return false; + } + + for (let index = 0; index < left.length; index += 1) { + if (keyOfDropTarget(left[index]) !== keyOfDropTarget(right[index])) { + return false; + } + } + + return true; +} + +function shouldNotifyMonitor(monitor: MonitorArgs, source: DragSource): boolean { + return monitor.canMonitor ? monitor.canMonitor({ source }) : true; +} + +function toNumeric(value: unknown): number | null { + if (typeof value !== "number" || Number.isNaN(value)) { + return null; + } + + if (!Number.isFinite(value)) { + return null; + } + + return value; +} + +function isPointInViewport(x: number, y: number): boolean { + return x >= 0 && y >= 0 && x <= window.innerWidth && y <= window.innerHeight; +} + +function normalizePoint(rawX: number, rawY: number): { x: number; y: number } { + const dpr = window.devicePixelRatio || 1; + const direct = { x: rawX, y: rawY }; + const scaled = { x: rawX / dpr, y: rawY / dpr }; + + const directValid = isPointInViewport(direct.x, direct.y); + const scaledValid = isPointInViewport(scaled.x, scaled.y); + + if (directValid && !scaledValid) { + return direct; + } + if (!directValid && scaledValid) { + return scaled; + } + if (directValid && scaledValid) { + return direct; + } + + if (lastKnownPoint) { + return lastKnownPoint; + } + + return { + x: Math.max(0, Math.min(window.innerWidth, direct.x)), + y: Math.max(0, Math.min(window.innerHeight, direct.y)), + }; +} + +function readDragInputLike(input: Partial): DragInput { + const rawX = toNumeric(input.clientX ?? input.x) ?? 0; + const rawY = toNumeric(input.clientY ?? input.y) ?? 0; + const normalized = normalizePoint(rawX, rawY); + lastKnownPoint = normalized; + + return { + clientX: normalized.x, + clientY: normalized.y, + x: normalized.x, + y: normalized.y, + altKey: Boolean(input.altKey), + }; +} + +function readDragInputFromEvent(event: DragEvent): DragInput { + return readDragInputLike({ + clientX: event.clientX, + clientY: event.clientY, + x: event.x, + y: event.y, + altKey: event.altKey, + }); +} + +function updateLocationForMonitors(nextLocation: DragLocation): void { + if (!activeDrag) { + return; + } + + if (areDropTargetsEqual(activeDrag.currentLocation.dropTargets, nextLocation.dropTargets)) { + activeDrag.currentLocation = nextLocation; + return; + } + + activeDrag.previousLocation = activeDrag.currentLocation; + activeDrag.currentLocation = nextLocation; + + for (const monitor of monitors) { + if (!shouldNotifyMonitor(monitor, activeDrag.source)) { + continue; + } + + monitor.onDropTargetChange?.({ + source: activeDrag.source, + location: { current: activeDrag.currentLocation, previous: activeDrag.previousLocation }, + }); + } +} + +function finalizeActiveDrag(input: DragInput): void { + if (!activeDrag) { + return; + } + + const current = activeDrag.currentLocation.dropTargets.length > 0 + ? activeDrag.currentLocation + : makeEmptyLocation(input); + const previous = activeDrag.previousLocation; + const source = activeDrag.source; + const sourceOnDrop = activeDrag.sourceOnDrop; + + for (const monitor of monitors) { + if (!shouldNotifyMonitor(monitor, source)) { + continue; + } + + monitor.onDrop?.({ source, location: { current, previous } }); + } + + sourceOnDrop?.(); + activeDrag = null; + lastKnownPoint = null; +} + +function refreshDropEffect(event: DragEvent, canDrop: boolean): void { + if (!event.dataTransfer) { + return; + } + + event.dataTransfer.dropEffect = canDrop ? "move" : "none"; +} + +function startInternalDrag(event: DragEvent, args: DraggableArgs): void { + const data = args.getInitialData(); + if (data === undefined) { + return; + } + + const input = readDragInputFromEvent(event); + activeDrag = { + source: { data, element: args.element }, + sourceOnDrop: args.onDrop, + currentLocation: makeEmptyLocation(input), + previousLocation: makeEmptyLocation(input), + }; + + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData(INTERNAL_MIME, "1"); + event.dataTransfer.setData("text/plain", "writer-sidebar-drag"); + } + + args.onDragStart?.(); + + for (const monitor of monitors) { + if (!shouldNotifyMonitor(monitor, activeDrag.source)) { + continue; + } + + monitor.onDragStart?.({ source: activeDrag.source }); + } +} + +export function draggable(args: DraggableArgs): () => void { + const { element } = args; + const previousDraggable = element.draggable; + element.draggable = true; + + const onDragStart = (event: DragEvent) => startInternalDrag(event, args); + + const onDragEnd = (event: DragEvent) => { + if (!activeDrag || activeDrag.source.element !== element) { + return; + } + + const input = readDragInputFromEvent(event); + finalizeActiveDrag(input); + }; + + element.addEventListener("dragstart", onDragStart); + element.addEventListener("dragend", onDragEnd); + + return () => { + element.removeEventListener("dragstart", onDragStart); + element.removeEventListener("dragend", onDragEnd); + element.draggable = previousDraggable; + if (activeDrag?.source.element === element) { + activeDrag = null; + lastKnownPoint = null; + } + }; +} + +export function dropTargetForElements(args: DropTargetArgs): () => void { + const { element } = args; + + const resolveNextLocation = (input: DragInput): DragLocation => { + if (!activeDrag) { + return makeEmptyLocation(input); + } + + if (!args.canDrop({ source: activeDrag.source })) { + return makeEmptyLocation(input); + } + + const data = args.getData({ source: activeDrag.source, input }); + if ( + data === null || data === undefined + || (typeof data === "object" && data && "targetType" in data + && (data as { targetType?: unknown }).targetType === "none") + ) { + return makeEmptyLocation(input); + } + + return { dropTargets: [{ element, data }], input }; + }; + + const onDragOver = (event: DragEvent) => { + if (!activeDrag) { + return; + } + + const input = readDragInputFromEvent(event); + const nextLocation = resolveNextLocation(input); + const canDrop = nextLocation.dropTargets.length > 0; + refreshDropEffect(event, canDrop); + if (canDrop) { + event.preventDefault(); + } + updateLocationForMonitors(nextLocation); + }; + + const onDragEnter = (event: DragEvent) => { + if (!activeDrag) { + return; + } + + const input = readDragInputFromEvent(event); + updateLocationForMonitors(resolveNextLocation(input)); + }; + + const onDragLeave = (event: DragEvent) => { + if (!activeDrag) { + return; + } + + const related = event.relatedTarget; + if (related instanceof Node && element.contains(related)) { + return; + } + + const input = readDragInputFromEvent(event); + updateLocationForMonitors(makeEmptyLocation(input)); + }; + + const onDrop = (event: DragEvent) => { + if (!activeDrag) { + return; + } + + const input = readDragInputFromEvent(event); + const nextLocation = resolveNextLocation(input); + if (nextLocation.dropTargets.length > 0) { + event.preventDefault(); + } + updateLocationForMonitors(nextLocation); + finalizeActiveDrag(input); + }; + + element.addEventListener("dragover", onDragOver); + element.addEventListener("dragenter", onDragEnter); + element.addEventListener("dragleave", onDragLeave); + element.addEventListener("drop", onDrop); + + return () => { + element.removeEventListener("dragover", onDragOver); + element.removeEventListener("dragenter", onDragEnter); + element.removeEventListener("dragleave", onDragLeave); + element.removeEventListener("drop", onDrop); + }; +} + +export function monitorForElements(args: MonitorArgs): () => void { + monitors.add(args); + return () => { + monitors.delete(args); + }; +} + +export function attachClosestEdge( + data: T, + options: { input: DragInput; element: HTMLElement; allowedEdges: Edge[] }, +): T & { [EDGE_KEY]?: Edge } { + if (options.allowedEdges.length === 0) { + return data; + } + + const rect = options.element.getBoundingClientRect(); + const midpoint = rect.top + rect.height / 2; + const edge: Edge = options.input.clientY <= midpoint ? "top" : "bottom"; + if (!options.allowedEdges.includes(edge)) { + return data; + } + + return { ...(data as Record), [EDGE_KEY]: edge } as T & { [EDGE_KEY]?: Edge }; +} + +export function extractClosestEdge(value: unknown): Edge | null { + if (!value || typeof value !== "object") { + return null; + } + + const edge = (value as { [EDGE_KEY]?: unknown })[EDGE_KEY]; + if (edge === "top" || edge === "bottom") { + return edge; + } + + return null; +} + +function ensureLiveRegion(): HTMLElement { + const existing = document.querySelector(`#${LIVE_REGION_ID}`); + if (existing instanceof HTMLElement) { + liveRegionState.node = existing; + return existing; + } + + const node = document.createElement("div"); + node.id = LIVE_REGION_ID; + node.setAttribute("role", "status"); + node.setAttribute("aria-live", "polite"); + node.setAttribute("aria-atomic", "true"); + node.style.position = "fixed"; + node.style.width = "1px"; + node.style.height = "1px"; + node.style.overflow = "hidden"; + node.style.clipPath = "inset(50%)"; + node.style.whiteSpace = "nowrap"; + node.style.pointerEvents = "none"; + document.body.append(node); + liveRegionState.node = node; + return node; +} + +export function announce(message: string): void { + if (!message.trim()) { + return; + } + + const liveRegion = ensureLiveRegion(); + if (liveRegionState.timer) { + clearTimeout(liveRegionState.timer); + } + + liveRegion.textContent = ""; + liveRegionState.timer = setTimeout(() => { + liveRegion.textContent = message; + }, 10); +} + +export function cleanup(): void { + if (liveRegionState.timer) { + clearTimeout(liveRegionState.timer); + liveRegionState.timer = null; + } + + liveRegionState.node?.remove(); + liveRegionState.node = null; +} + +function parseLocationId(locationIdRaw: string | undefined): number | null { + if (!locationIdRaw) { + return null; + } + + const locationId = parseInt(locationIdRaw, 10); + return Number.isNaN(locationId) ? null : locationId; +} + +function toDestinationDataFromElement(element: HTMLElement): DestinationData | null { + const locationId = parseLocationId(element.dataset.locationId); + if (locationId === null) { + return null; + } + + if (element.dataset.documentPath) { + return { locationId, relPath: element.dataset.documentPath, targetType: "document" }; + } + + if (element.dataset.folderPath) { + return { locationId, folderPath: element.dataset.folderPath, targetType: "folder" }; + } + + return { locationId, targetType: "location" }; +} + +export function resolveDestinationFromPointer( + x: number, + y: number, +): { destination: DestinationData; element: HTMLElement } | null { + const hitElement = document.elementFromPoint(x, y); + const locationElement = hitElement instanceof HTMLElement + ? hitElement.closest("[data-location-id]") + : null; + if (locationElement) { + const destination = toDestinationDataFromElement(locationElement); + if (destination) { + return { destination, element: locationElement }; + } + } + + const allTargets = document.querySelectorAll( + "[data-drop-folder-row][data-location-id], [data-drop-document-row][data-location-id], [data-drop-location-root][data-location-id]", + ); + + let best: { destination: DestinationData; element: HTMLElement; priority: number; area: number } | null = null; + + for (const element of allTargets) { + const rect = element.getBoundingClientRect(); + if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) { + continue; + } + + const destination = toDestinationDataFromElement(element); + if (!destination) { + continue; + } + + const priority = destination.targetType === "folder" ? 3 : (destination.targetType === "document" ? 2 : 1); + const area = Math.max(1, rect.width * rect.height); + + if (!best || priority > best.priority || (priority === best.priority && area < best.area)) { + best = { destination, element, priority, area }; + } + } + + if (!best) { + let nearestDistance = Infinity; + let nearest: { destination: DestinationData; element: HTMLElement } | null = null; + + for (const element of allTargets) { + const rect = element.getBoundingClientRect(); + if (x < rect.left || x > rect.right) { + continue; + } + + const destination = toDestinationDataFromElement(element); + if (!destination) { + continue; + } + + const distance = y < rect.top ? rect.top - y : (y > rect.bottom ? y - rect.bottom : 0); + if (distance < nearestDistance) { + nearestDistance = distance; + nearest = { destination, element }; + } + } + + if (nearest && nearestDistance < 64) { + return nearest; + } + + return null; + } + + return { destination: best.destination, element: best.element }; +} + +export function normalizePointerCoordinates(x: number, y: number): { x: number; y: number } { + return normalizePoint(x, y); +} diff --git a/src/dnd/sidebar.ts b/src/dnd/sidebar.ts new file mode 100644 index 0000000..534f869 --- /dev/null +++ b/src/dnd/sidebar.ts @@ -0,0 +1,265 @@ +import type { DocumentDragData } from "$components/Sidebar/DocumentItem"; +import { type DestinationData, type Edge } from "$dnd"; +import type { DocMeta } from "$types"; + +export type FolderDragData = { type: "folder"; locationId: number; relPath: string; title: string }; +export type SidebarDragData = DocumentDragData | FolderDragData; + +type DestinationDropTarget = { data?: unknown }; +type PointerInfo = { x?: number; y?: number; altKey?: boolean }; + +function splitPathSegments(relPath: string): string[] { + return relPath.split(/[\\/]+/).filter(Boolean); +} + +function isFolderDropNoop(sourcePath: string, destinationParentPath: string): boolean { + return getParentDirectoryPath(sourcePath) === destinationParentPath; +} + +function isDestinationData(value: unknown): value is DestinationData { + if (!value || typeof value !== "object") { + return false; + } + + const maybe = value as Partial; + return typeof maybe.locationId === "number" && Number.isFinite(maybe.locationId); +} + +function destinationPriority(destination: DestinationData): number { + if (destination.targetType === "document") { + return 3; + } + if (destination.targetType === "folder" || destination.folderPath) { + return 2; + } + return 1; +} + +function canDropIntoLocationTarget(sourceData: SidebarDragData, locationId: number): boolean { + if (sourceData.type === "folder") { + return sourceData.locationId === locationId && getParentDirectoryPath(sourceData.relPath) !== ""; + } + + return sourceData.type === "document"; +} + +function canDropIntoDocumentTarget(sourceData: SidebarDragData, destination: DestinationData): boolean { + if (sourceData.type !== "document" || destination.targetType !== "document" || !destination.relPath) { + return false; + } + + return sourceData.locationId === destination.locationId && sourceData.relPath !== destination.relPath; +} + +export function isDocumentDragData(value: unknown): value is DocumentDragData { + if (!value || typeof value !== "object") { + return false; + } + + const maybe = value as Partial; + return maybe.type === "document" && typeof maybe.locationId === "number" && typeof maybe.relPath === "string"; +} + +export function isFolderDragData(sourceData: unknown): sourceData is FolderDragData { + if (!sourceData || typeof sourceData !== "object") { + return false; + } + + const data = sourceData as Partial; + return data.type === "folder" && typeof data.locationId === "number" && typeof data.relPath === "string"; +} + +export function isSidebarDragData(value: unknown): value is SidebarDragData { + return isDocumentDragData(value) || isFolderDragData(value); +} + +export function getFilename(relPath: string): string { + return relPath.split("/").pop() || relPath; +} + +export function getParentDirectoryPath(relPath: string): string { + const parts = splitPathSegments(relPath); + return parts.length > 1 ? parts.slice(0, -1).join("/") : ""; +} + +export function canDropDocumentIntoFolder(sourceData: unknown, locationId: number, folderPath: string): boolean { + if (!isDocumentDragData(sourceData)) { + return false; + } + + if (sourceData.locationId !== locationId) { + return true; + } + + return getParentDirectoryPath(sourceData.relPath) !== folderPath; +} + +export function canDropFolderIntoFolder(sourceData: unknown, locationId: number, folderPath: string): boolean { + if (!isFolderDragData(sourceData)) { + return false; + } + + if (sourceData.locationId !== locationId) { + return false; + } + + if (sourceData.relPath === folderPath) { + return false; + } + + if (folderPath.startsWith(`${sourceData.relPath}/`)) { + return false; + } + + return !isFolderDropNoop(sourceData.relPath, folderPath); +} + +export function resolveDestinationFromDropTargets(dropTargets: unknown): DestinationData | null { + if (!Array.isArray(dropTargets)) { + return null; + } + + let best: DestinationData | null = null; + for (const target of dropTargets as DestinationDropTarget[]) { + const data = target.data; + if (!isDestinationData(data)) { + continue; + } + + if (!best || destinationPriority(data) > destinationPriority(best)) { + best = data; + } + } + + return best; +} + +export function canDropIntoDestination(sourceData: SidebarDragData, destination: DestinationData): boolean { + if (destination.folderPath) { + if (sourceData.type === "folder") { + return canDropFolderIntoFolder(sourceData, destination.locationId, destination.folderPath); + } + + return canDropDocumentIntoFolder(sourceData, destination.locationId, destination.folderPath); + } + + if (destination.targetType === "document") { + return canDropIntoDocumentTarget(sourceData, destination); + } + + return canDropIntoLocationTarget(sourceData, destination.locationId); +} + +export function walkUpToValidDestination( + sourceData: SidebarDragData, + destination: DestinationData, +): DestinationData | null { + if (canDropIntoDestination(sourceData, destination)) { + return destination; + } + + if (!destination.folderPath) { + return null; + } + + let currentPath = destination.folderPath; + while (currentPath) { + const parentPath = getParentDirectoryPath(currentPath); + if (parentPath === currentPath) { + break; + } + + if (parentPath) { + const parentDestination: DestinationData = { + locationId: destination.locationId, + folderPath: parentPath, + targetType: "folder", + }; + if (canDropIntoDestination(sourceData, parentDestination)) { + return parentDestination; + } + } else { + const rootDestination: DestinationData = { locationId: destination.locationId, targetType: "location" }; + if (canDropIntoDestination(sourceData, rootDestination)) { + return rootDestination; + } + break; + } + + currentPath = parentPath; + } + + return null; +} + +export function summarizePointerInput(input: unknown): PointerInfo & { rawX?: number; rawY?: number; dpr?: number } { + if (!input || typeof input !== "object") { + return {}; + } + + const maybe = input as Partial<{ clientX: number; clientY: number; x: number; y: number; altKey: boolean }>; + const dpr = window.devicePixelRatio || 1; + const rawX = typeof maybe.clientX === "number" ? maybe.clientX : maybe.x; + const rawY = typeof maybe.clientY === "number" ? maybe.clientY : maybe.y; + return { + x: typeof rawX === "number" ? rawX / dpr : undefined, + y: typeof rawY === "number" ? rawY / dpr : undefined, + rawX, + rawY, + dpr, + altKey: typeof maybe.altKey === "boolean" ? maybe.altKey : undefined, + }; +} + +export function pointerFromInput(input: unknown): { x: number; y: number } | null { + if (!input || typeof input !== "object") { + return null; + } + + const maybe = input as Partial<{ clientX: number; clientY: number; x: number; y: number }>; + const x = typeof maybe.clientX === "number" ? maybe.clientX : maybe.x; + const y = typeof maybe.clientY === "number" ? maybe.clientY : maybe.y; + if (typeof x !== "number" || typeof y !== "number") { + return null; + } + + return { x, y }; +} + +export function reorderDocumentsInLocation( + documents: DocMeta[], + locationId: number, + sourceRelPath: string, + destinationRelPath: string, + edge: Edge | null, +): DocMeta[] { + if (edge !== "top" && edge !== "bottom") { + return documents; + } + + const locationDocuments = documents.filter((doc) => doc.location_id === locationId); + const sourceIndex = locationDocuments.findIndex((doc) => doc.rel_path === sourceRelPath); + if (sourceIndex === -1) { + return documents; + } + + const [sourceDoc] = locationDocuments.splice(sourceIndex, 1); + const destinationIndex = locationDocuments.findIndex((doc) => doc.rel_path === destinationRelPath); + if (destinationIndex === -1) { + return documents; + } + + const insertIndex = edge === "top" ? destinationIndex : destinationIndex + 1; + locationDocuments.splice(insertIndex, 0, sourceDoc); + + let locationCursor = 0; + return documents.map((doc) => { + if (doc.location_id !== locationId) { + return doc; + } + + const next = locationDocuments[locationCursor]; + locationCursor += 1; + return next; + }); +} diff --git a/src/editor/focus-dimming.ts b/src/editor/focus-dimming.ts index c18f992..845a27f 100644 --- a/src/editor/focus-dimming.ts +++ b/src/editor/focus-dimming.ts @@ -1,7 +1,7 @@ +import type { FocusDimmingMode } from "$types"; import { RangeSetBuilder } from "@codemirror/state"; import type { Extension } from "@codemirror/state"; import { Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view"; -import type { FocusDimmingMode } from "../types"; const DIMMED_CLASS = "cm-dimmed-text"; type Region = { start: number; end: number }; diff --git a/src/hooks/controllers/useSidebarActions.ts b/src/hooks/controllers/useSidebarActions.ts new file mode 100644 index 0000000..937e94e --- /dev/null +++ b/src/hooks/controllers/useSidebarActions.ts @@ -0,0 +1,379 @@ +import { + dirList, + dirMove, + docDelete, + docList, + docMove, + docRename, + docSave, + locationAddViaDialog, + locationList, + locationRemove, + runCmd, + sessionDropDoc, + sessionOpenTab, + sessionUpdateTabDoc, +} from "$ports"; +import { + useTabsActions, + useTabsState, + useWorkspaceDocumentsActions, + useWorkspaceLocationsActions, +} from "$state/selectors"; +import { useWorkspaceStore } from "$state/stores/workspace"; +import type { SidebarRefreshReason } from "$state/types"; +import type { AppError, DocMeta, DocRef, SessionState } from "$types"; +import { buildDraftRelPath, getDraftTitle } from "$utils/paths"; +import { f } from "$utils/serialize"; +import * as logger from "@tauri-apps/plugin-log"; +import { useCallback, useMemo } from "react"; + +const TRANSIENT_EMPTY_REFRESH_RETRY_DELAY_MS = 120; + +type RefreshSidebarOptions = { source?: SidebarRefreshReason; attempt?: number }; + +function toLocationId(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function areDocumentsEqual(left: DocMeta[], right: DocMeta[]): boolean { + if (left.length !== right.length) { + return false; + } + + for (let index = 0; index < left.length; index += 1) { + const a = left[index]; + const b = right[index]; + if ( + a.location_id !== b.location_id + || a.rel_path !== b.rel_path + || a.title !== b.title + || a.updated_at !== b.updated_at + || a.word_count !== b.word_count + ) { + return false; + } + } + + return true; +} + +function areDirectoriesEqual(left: string[], right: string[]): boolean { + if (left.length !== right.length) { + return false; + } + + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) { + return false; + } + } + + return true; +} + +function mapDirectoryMovedRelPath(sourceDir: string, destinationDir: string, candidate: string): string | null { + if (candidate === sourceDir) { + return destinationDir; + } + + const prefix = `${sourceDir}/`; + if (!candidate.startsWith(prefix)) { + return null; + } + + const suffix = candidate.slice(prefix.length); + return suffix ? `${destinationDir}/${suffix}` : destinationDir; +} + +export function useSidebarActions() { + const { setSidebarRefreshState } = useWorkspaceDocumentsActions(); + const { setLocations, setSelectedLocation } = useWorkspaceLocationsActions(); + const { tabs } = useTabsState(); + const { applySessionState } = useTabsActions(); + + const applySession = useCallback((session: SessionState) => { + applySessionState(session); + }, [applySessionState]); + + const refreshLocations = useCallback((nextSelectedLocationId?: number) => { + runCmd(locationList((nextLocations) => { + setLocations(nextLocations); + if (nextSelectedLocationId && nextLocations.some((location) => location.id === nextSelectedLocationId)) { + setSelectedLocation(nextSelectedLocationId); + } + }, (error) => { + logger.error(f("Failed to refresh locations", { error })); + })); + }, [setLocations, setSelectedLocation]); + + const handleAddLocation = useCallback(() => { + runCmd(locationAddViaDialog((location) => { + refreshLocations(location.id); + }, (error) => { + logger.error(f("Failed to add location", { error })); + })); + }, [refreshLocations]); + + const handleRemoveLocation = useCallback((locationId: number) => { + runCmd(locationRemove(locationId, (removed) => { + if (removed) { + refreshLocations(); + } + }, (error) => { + logger.error(f("Failed to remove location", { locationId, error })); + })); + }, [refreshLocations]); + + const openTab = useCallback((docRef: DocRef, title: string) => { + void runCmd(sessionOpenTab(docRef, title, applySession, (error) => { + logger.error(f("Failed to open session tab", { docRef, title, error })); + })); + }, [applySession]); + + const handleSelectDocument = useCallback((locationId: number, path: string) => { + const docTitle = useWorkspaceStore.getState().documents.find((doc) => + doc.location_id === locationId && doc.rel_path === path + )?.title; + + const title = docTitle || path.split("/").pop() || "Untitled"; + openTab({ location_id: locationId, rel_path: path }, title); + }, [openTab]); + + const handleCreateNewDocument = useCallback((locationId?: number) => { + const workspaceState = useWorkspaceStore.getState(); + const requestedLocationId = toLocationId(locationId); + const targetLocationId = requestedLocationId ?? workspaceState.selectedLocationId + ?? workspaceState.locations[0]?.id; + + if (!targetLocationId) { + logger.warn("Cannot create draft without a selected location."); + return null; + } + + const relPath = buildDraftRelPath(targetLocationId, workspaceState.documents, tabs); + const docRef: DocRef = { location_id: targetLocationId, rel_path: relPath }; + openTab(docRef, getDraftTitle(relPath)); + return docRef; + }, [openTab, tabs]); + + const handleRefreshSidebar = useCallback((locationId?: number, options: RefreshSidebarOptions = {}) => { + const source = options.source ?? "manual"; + const attempt = options.attempt ?? 0; + const workspaceState = useWorkspaceStore.getState(); + const requestedLocationId = toLocationId(locationId); + const targetLocationId = requestedLocationId ?? workspaceState.selectedLocationId + ?? workspaceState.locations[0]?.id; + + if (!targetLocationId || workspaceState.selectedLocationId !== targetLocationId) { + return; + } + + setSidebarRefreshState(targetLocationId, source); + + runCmd(dirList(targetLocationId, (nextDirectories) => { + const latestState = useWorkspaceStore.getState(); + if (latestState.selectedLocationId !== targetLocationId) { + return; + } + + if (!areDirectoriesEqual(latestState.directories, nextDirectories)) { + latestState.setDirectories(nextDirectories); + } + }, (error) => { + logger.error(f("Failed to refresh sidebar directories", { locationId: targetLocationId, error })); + })); + + runCmd(docList(targetLocationId, (nextDocuments) => { + const latestState = useWorkspaceStore.getState(); + if (latestState.selectedLocationId !== targetLocationId) { + if (latestState.refreshingLocationId === targetLocationId) { + latestState.setSidebarRefreshState(undefined, null); + } + return; + } + + if (nextDocuments.length === 0 && latestState.documents.length > 0 && attempt === 0) { + setTimeout(() => { + handleRefreshSidebar(targetLocationId, { source, attempt: attempt + 1 }); + }, TRANSIENT_EMPTY_REFRESH_RETRY_DELAY_MS); + return; + } + + if (!areDocumentsEqual(latestState.documents, nextDocuments)) { + latestState.setDocuments(nextDocuments); + } + + if (latestState.refreshingLocationId === targetLocationId) { + latestState.setSidebarRefreshState(undefined, null); + } + }, (error) => { + if (attempt === 0) { + setTimeout(() => { + handleRefreshSidebar(targetLocationId, { source, attempt: attempt + 1 }); + }, TRANSIENT_EMPTY_REFRESH_RETRY_DELAY_MS); + return; + } + + logger.error(f("Failed to refresh sidebar documents", { locationId: targetLocationId, error })); + const latestState = useWorkspaceStore.getState(); + if (latestState.refreshingLocationId === targetLocationId) { + latestState.setSidebarRefreshState(undefined, null); + } + })); + }, [setSidebarRefreshState]); + + const handleRenameDocument = useCallback((locationId: number, relPath: string, newName: string): Promise => { + return new Promise((resolve) => { + runCmd(docRename(locationId, relPath, newName, (newMeta) => { + void runCmd( + sessionUpdateTabDoc( + locationId, + relPath, + { location_id: locationId, rel_path: newMeta.rel_path }, + newMeta.title, + applySession, + () => {}, + ), + ); + + logger.info(f("Document renamed", { locationId, oldPath: relPath, newPath: newMeta.rel_path })); + resolve(true); + }, (error: AppError) => { + logger.error(f("Failed to rename document", { locationId, relPath, newName, error })); + resolve(false); + })); + }); + }, [applySession]); + + const handleMoveDocument = useCallback( + (locationId: number, relPath: string, newRelPath: string, targetLocationId?: number): Promise => { + return new Promise((resolve) => { + runCmd(docMove(locationId, relPath, newRelPath, (newMeta) => { + void runCmd( + sessionUpdateTabDoc( + locationId, + relPath, + { location_id: newMeta.location_id, rel_path: newMeta.rel_path }, + newMeta.title, + applySession, + () => {}, + ), + ); + + logger.info( + f("Document moved", { + sourceLocationId: locationId, + targetLocationId: newMeta.location_id, + oldPath: relPath, + newPath: newMeta.rel_path, + }), + ); + resolve(true); + }, (error: AppError) => { + logger.error(f("Failed to move document", { locationId, relPath, newRelPath, targetLocationId, error })); + resolve(false); + }, targetLocationId)); + }); + }, + [applySession], + ); + + const handleDeleteDocument = useCallback((locationId: number, relPath: string): Promise => { + return new Promise((resolve) => { + runCmd(docDelete(locationId, relPath, (deleted) => { + if (!deleted) { + resolve(false); + return; + } + + void runCmd(sessionDropDoc(locationId, relPath, applySession, () => {})); + + logger.info(f("Document deleted", { locationId, relPath })); + resolve(true); + }, (error: AppError) => { + logger.error(f("Failed to delete document", { locationId, relPath, error })); + resolve(false); + })); + }); + }, [applySession]); + + const handleMoveDirectory = useCallback( + (locationId: number, relPath: string, newRelPath: string): Promise => { + return new Promise((resolve) => { + runCmd(dirMove(locationId, relPath, newRelPath, (resolvedPath) => { + for (const tab of tabs) { + if (tab.docRef.location_id !== locationId) { + continue; + } + + const remappedRelPath = mapDirectoryMovedRelPath(relPath, resolvedPath, tab.docRef.rel_path); + if (!remappedRelPath) { + continue; + } + + void runCmd( + sessionUpdateTabDoc( + locationId, + tab.docRef.rel_path, + { location_id: locationId, rel_path: remappedRelPath }, + tab.title, + applySession, + () => {}, + ), + ); + } + + logger.info(f("Directory moved", { locationId, relPath, newRelPath: resolvedPath })); + resolve(true); + }, (error: AppError) => { + logger.error(f("Failed to move directory", { locationId, relPath, newRelPath, error })); + resolve(false); + })); + }); + }, + [applySession, tabs], + ); + + const handleImportExternalFile = useCallback( + (locationId: number, relPath: string, content: string): Promise => { + return new Promise((resolve) => { + runCmd(docSave(locationId, relPath, content, (result) => { + logger.info(f("External file imported", { locationId, relPath, result })); + resolve(true); + }, (error: AppError) => { + logger.error(f("Failed to import external file", { locationId, relPath, error })); + resolve(false); + })); + }); + }, + [], + ); + + return useMemo( + () => ({ + handleAddLocation, + handleRemoveLocation, + handleSelectDocument, + handleCreateNewDocument, + handleRefreshSidebar, + handleRenameDocument, + handleMoveDocument, + handleMoveDirectory, + handleDeleteDocument, + handleImportExternalFile, + }), + [ + handleAddLocation, + handleRemoveLocation, + handleSelectDocument, + handleCreateNewDocument, + handleRefreshSidebar, + handleRenameDocument, + handleMoveDocument, + handleMoveDirectory, + handleDeleteDocument, + handleImportExternalFile, + ], + ); +} diff --git a/src/hooks/controllers/useWorkspaceController.ts b/src/hooks/controllers/useWorkspaceController.ts index d3143d7..62a1039 100644 --- a/src/hooks/controllers/useWorkspaceController.ts +++ b/src/hooks/controllers/useWorkspaceController.ts @@ -1,5 +1,7 @@ import { dirCreate, + dirList, + dirMove, docDelete, docList, docMove, @@ -65,6 +67,34 @@ function areDocumentsEqual(left: DocMeta[], right: DocMeta[]): boolean { return true; } +function areDirectoriesEqual(left: string[], right: string[]): boolean { + if (left.length !== right.length) { + return false; + } + + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) { + return false; + } + } + + return true; +} + +function mapDirectoryMovedRelPath(sourceDir: string, destinationDir: string, candidate: string): string | null { + if (candidate === sourceDir) { + return destinationDir; + } + + const prefix = `${sourceDir}/`; + if (!candidate.startsWith(prefix)) { + return null; + } + + const suffix = candidate.slice(prefix.length); + return suffix ? `${destinationDir}/${suffix}` : destinationDir; +} + export function useWorkspaceController() { const { locations, selectedLocationId, isLoadingLocations, sidebarFilter } = useWorkspaceLocationsState(); const { selectedDocPath, documents, isLoadingDocuments, refreshingLocationId, sidebarRefreshReason } = @@ -211,6 +241,19 @@ export function useWorkspaceController() { setSidebarRefreshState(targetLocationId, source); + runCmd(dirList(targetLocationId, (nextDirectories) => { + const latestState = useWorkspaceStore.getState(); + if (latestState.selectedLocationId !== targetLocationId) { + return; + } + + if (!areDirectoriesEqual(latestState.directories, nextDirectories)) { + latestState.setDirectories(nextDirectories); + } + }, (error) => { + logger.error(f("Failed to refresh sidebar directories", { locationId: targetLocationId, error })); + })); + runCmd(docList(targetLocationId, (nextDocuments) => { const latestState = useWorkspaceStore.getState(); if (latestState.selectedLocationId !== targetLocationId) { @@ -325,6 +368,43 @@ export function useWorkspaceController() { }); }, [applySession]); + const handleMoveDirectory = useCallback( + (locationId: number, relPath: string, newRelPath: string): Promise => { + return new Promise((resolve) => { + runCmd(dirMove(locationId, relPath, newRelPath, (resolvedPath) => { + for (const tab of tabs) { + if (tab.docRef.location_id !== locationId) { + continue; + } + + const remappedRelPath = mapDirectoryMovedRelPath(relPath, resolvedPath, tab.docRef.rel_path); + if (!remappedRelPath) { + continue; + } + + void runCmd( + sessionUpdateTabDoc( + locationId, + tab.docRef.rel_path, + { location_id: locationId, rel_path: remappedRelPath }, + tab.title, + applySession, + () => {}, + ), + ); + } + + logger.info(f("Directory moved", { locationId, relPath, newRelPath: resolvedPath })); + resolve(true); + }, (error: AppError) => { + logger.error(f("Failed to move directory", { locationId, relPath, newRelPath, error })); + resolve(false); + })); + }); + }, + [applySession, tabs], + ); + const handleImportExternalFile = useCallback( (locationId: number, relPath: string, content: string): Promise => { return new Promise((resolve) => { @@ -393,6 +473,7 @@ export function useWorkspaceController() { handleRefreshSidebar, handleRenameDocument, handleMoveDocument, + handleMoveDirectory, handleDeleteDocument, handleCreateDirectory, handleImportExternalFile, @@ -426,6 +507,7 @@ export function useWorkspaceController() { handleRefreshSidebar, handleRenameDocument, handleMoveDocument, + handleMoveDirectory, handleDeleteDocument, handleCreateDirectory, handleImportExternalFile, diff --git a/src/hooks/useExternalDropHandler.ts b/src/hooks/useExternalDropHandler.ts index b8a4f71..4698ad4 100644 --- a/src/hooks/useExternalDropHandler.ts +++ b/src/hooks/useExternalDropHandler.ts @@ -1,4 +1,4 @@ -import { useWorkspaceController } from "$hooks/controllers/useWorkspaceController"; +import { normalizePointerCoordinates, resolveDestinationFromPointer } from "$dnd"; import { showSuccessToast, showWarnToast } from "$state/stores/toasts"; import type { DocMeta } from "$types"; import { f } from "$utils/serialize"; @@ -10,26 +10,32 @@ import { useEffect, useRef } from "react"; type DropTargetInfo = { locationId: number; folderPath?: string }; +function getParentDirectoryPath(relPath: string): string | undefined { + const parts = relPath.split("/").filter(Boolean); + if (parts.length <= 1) { + return undefined; + } + + return parts.slice(0, -1).join("/"); +} + function resolveDropTarget(x: number, y: number): DropTargetInfo | null { - const element = document.elementFromPoint(x, y); - if (!element) return null; - - let current: Element | null = element; - while (current) { - const folderPath = (current as HTMLElement).dataset.folderPath; - const locationId = (current as HTMLElement).dataset.locationId; - if (folderPath && locationId) { - const parsed = parseInt(locationId, 10); - if (!isNaN(parsed)) return { locationId: parsed, folderPath }; - } - if (locationId) { - const parsed = parseInt(locationId, 10); - if (!isNaN(parsed)) return { locationId: parsed }; - } - current = current.parentElement; + const point = normalizePointerCoordinates(x, y); + const target = resolveDestinationFromPointer(point.x, point.y); + if (!target) { + return null; + } + + if (target.destination.folderPath) { + return { locationId: target.destination.locationId, folderPath: target.destination.folderPath }; } - return null; + if (target.destination.targetType === "document" && target.destination.relPath) { + const parentFolderPath = getParentDirectoryPath(target.destination.relPath); + return { locationId: target.destination.locationId, ...(parentFolderPath ? { folderPath: parentFolderPath } : {}) }; + } + + return { locationId: target.destination.locationId }; } export function useExternalDropHandler( @@ -37,9 +43,10 @@ export function useExternalDropHandler( documents: DocMeta[], setExternalDropTarget: (locationId?: number) => void, refreshSidebar: (locationId?: number) => void, + handleImportExternalFile: (locationId: number, relPath: string, content: string) => Promise, ) { - const { handleImportExternalFile } = useWorkspaceController(); const dropTargetRef = useRef(null); + const hasExternalFileDragRef = useRef(false); useEffect(() => { const window = getCurrentWindow(); @@ -47,83 +54,113 @@ export function useExternalDropHandler( const unlisten = window.onDragDropEvent(async (event) => { const dragEvent = event.payload as DragDropEvent; - if (dragEvent.type === "over") { - const target = resolveDropTarget(dragEvent.position.x, dragEvent.position.y); - const targetId = target?.locationId ?? selectedLocationId ?? null; - const targetKey = target ? `${target.locationId}:${target.folderPath ?? ""}` : null; - const currentKey = dropTargetRef.current - ? `${dropTargetRef.current.locationId}:${dropTargetRef.current.folderPath ?? ""}` - : null; - - if (targetKey !== currentKey) { - dropTargetRef.current = target ?? (targetId ? { locationId: targetId } : null); - setExternalDropTarget(targetId ?? undefined); - } - } else if (dragEvent.type === "drop") { - const target = dropTargetRef.current; - const targetId = target?.locationId ?? selectedLocationId; - - dropTargetRef.current = null; - setExternalDropTarget(undefined); - - if (!targetId) { - logger.info("External file drop ignored: no target location"); - return; + switch (dragEvent.type) { + case "enter": { + hasExternalFileDragRef.current = dragEvent.paths.length > 0; + if (!hasExternalFileDragRef.current) { + dropTargetRef.current = null; + setExternalDropTarget(undefined); + } + break; } + case "over": { + if (!hasExternalFileDragRef.current) { + return; + } - const files = dragEvent.paths.filter((path) => path.toLowerCase().endsWith(".md")); + const target = resolveDropTarget(dragEvent.position.x, dragEvent.position.y); + const targetId = target?.locationId ?? selectedLocationId ?? null; + const targetKey = target ? `${target.locationId}:${target.folderPath ?? ""}` : null; + const currentKey = dropTargetRef.current + ? `${dropTargetRef.current.locationId}:${dropTargetRef.current.folderPath ?? ""}` + : null; - if (files.length === 0) { - logger.info("External file drop ignored: no markdown files"); - showWarnToast("Only .md files can be imported"); - return; + if (targetKey !== currentKey) { + dropTargetRef.current = target ?? (targetId ? { locationId: targetId } : null); + setExternalDropTarget(targetId ?? undefined); + } + break; } + case "drop": { + const droppedPaths = dragEvent.paths; + const isExternalFileDrop = hasExternalFileDragRef.current || droppedPaths.length > 0; + const target = dropTargetRef.current; + const targetId = target?.locationId ?? selectedLocationId; + + hasExternalFileDragRef.current = false; + dropTargetRef.current = null; + setExternalDropTarget(undefined); + + if (!isExternalFileDrop || droppedPaths.length === 0) { + return; + } - logger.info( - f("Importing external files", { count: files.length, locationId: targetId, folderPath: target?.folderPath }), - ); + if (!targetId) { + logger.info("External file drop ignored: no target location"); + return; + } - const existingPaths = new Set( - documents.filter((d) => d.location_id === targetId).map((d) => d.rel_path.toLowerCase()), - ); + const files = droppedPaths.filter((path) => path.toLowerCase().endsWith(".md")); - let successCount = 0; - let skipCount = 0; + if (files.length === 0) { + logger.info("External file drop ignored: no markdown files"); + showWarnToast("Only .md files can be imported"); + return; + } - for (const filePath of files) { - const filename = filePath.split(/[\\/]/).pop() ?? "imported.md"; - const relPath = target?.folderPath ? `${target.folderPath}/${filename}` : filename; + logger.info( + f("Importing external files", { + count: files.length, + locationId: targetId, + folderPath: target?.folderPath, + }), + ); + + const existingPaths = new Set( + documents.filter((d) => d.location_id === targetId).map((d) => d.rel_path.toLowerCase()), + ); + + let successCount = 0; + let skipCount = 0; + + for (const filePath of files) { + const filename = filePath.split(/[\\/]/).pop() ?? "imported.md"; + const relPath = target?.folderPath ? `${target.folderPath}/${filename}` : filename; + + if (existingPaths.has(relPath.toLowerCase())) { + logger.warn(f("Skipping file that already exists", { filePath, relPath })); + skipCount++; + continue; + } + + try { + const content = await readTextFile(filePath); + const success = await handleImportExternalFile(targetId, relPath, content); + if (success) successCount++; + } catch (error) { + logger.error(f("Failed to import file", { filePath, error })); + } + } - if (existingPaths.has(relPath.toLowerCase())) { - logger.warn(f("Skipping file that already exists", { filePath, relPath })); - skipCount++; - continue; + if (successCount > 0) { + refreshSidebar(targetId); + showSuccessToast(`Imported ${successCount} file${successCount === 1 ? "" : "s"}`); } - try { - const content = await readTextFile(filePath); - const success = await handleImportExternalFile(targetId, relPath, content); - if (success) successCount++; - } catch (error) { - logger.error(f("Failed to import file", { filePath, error })); + if (skipCount > 0) { + showWarnToast(`Skipped ${skipCount} file${skipCount === 1 ? "" : "s"} (already exists)`); } - } - if (successCount > 0) { - refreshSidebar(targetId); - showSuccessToast(`Imported ${successCount} file${successCount === 1 ? "" : "s"}`); + logger.info( + f("External file import complete", { success: successCount, skipped: skipCount, total: files.length }), + ); + break; } - - if (skipCount > 0) { - showWarnToast(`Skipped ${skipCount} file${skipCount === 1 ? "" : "s"} (already exists)`); + default: { + hasExternalFileDragRef.current = false; + dropTargetRef.current = null; + setExternalDropTarget(undefined); } - - logger.info( - f("External file import complete", { success: successCount, skipped: skipCount, total: files.length }), - ); - } else { - dropTargetRef.current = null; - setExternalDropTarget(undefined); } }); diff --git a/src/ports/commands.ts b/src/ports/commands.ts index 595dab8..217bdbc 100644 --- a/src/ports/commands.ts +++ b/src/ports/commands.ts @@ -23,6 +23,7 @@ import type { Cmd, DirCreateParams, DirDeleteParams, + DirListParams, DirMoveParams, DirRenameParams, DocDeleteParams, @@ -153,6 +154,10 @@ export function docList(...[locationId, onOk, onErr]: DocListParams): return invokeCmd("doc_list", { locationId }, onOk, onErr); } +export function dirList(...[locationId, onOk, onErr]: DirListParams): Cmd { + return invokeCmd("dir_list", { locationId }, onOk, onErr); +} + export function docOpen(...[locationId, relPath, onOk, onErr]: DocOpenParams): Cmd { return invokeCmd("doc_open", { locationId, relPath }, onOk, onErr); } diff --git a/src/ports/invoke.ts b/src/ports/invoke.ts index 88b15d7..8e8f95a 100644 --- a/src/ports/invoke.ts +++ b/src/ports/invoke.ts @@ -276,6 +276,13 @@ function normalizeCommandValue(command: string, value: unknown): unknown { } return value.map((doc) => normalizeDocMeta(doc)); } + case "dir_list": { + if (!Array.isArray(value)) { + return []; + } + + return value.filter((entry): entry is string => typeof entry === "string"); + } case "doc_open": { if (!isRecord(value)) { return value; diff --git a/src/ports/types.ts b/src/ports/types.ts index c7af414..1cad3f6 100644 --- a/src/ports/types.ts +++ b/src/ports/types.ts @@ -116,6 +116,7 @@ export type DocMoveParams = Parameters< >; export type DocDeleteParams = [...LocationPathParams, ...LocParams]; export type DirCreateParams = [...LocationPathParams, ...LocParams]; +export type DirListParams = [...LocationIdParams, ...LocParams]; export type DirDeleteParams = [...LocationPathParams, ...LocParams]; export type DirRenameParams = Parameters< (locationId: LocationId, relPath: string, newName: string, onOk: SuccessCallback, onErr: ErrorCallback) => void diff --git a/src/state/selectors.ts b/src/state/selectors.ts index c21d134..989b67c 100644 --- a/src/state/selectors.ts +++ b/src/state/selectors.ts @@ -145,6 +145,7 @@ export const useWorkspaceDocumentsState = () => useShallow((state) => ({ selectedDocPath: state.selectedDocPath, documents: state.documents, + directories: state.directories, isLoadingDocuments: state.isLoadingDocuments, refreshingLocationId: state.refreshingLocationId, sidebarRefreshReason: state.sidebarRefreshReason, @@ -157,6 +158,7 @@ export const useWorkspaceDocumentsActions = () => useShallow((state) => ({ setSelectedDocPath: state.setSelectedDocPath, setDocuments: state.setDocuments, + setDirectories: state.setDirectories, setLoadingDocuments: state.setLoadingDocuments, setSidebarRefreshState: state.setSidebarRefreshState, setExternalDropTarget: state.setExternalDropTarget, @@ -371,6 +373,7 @@ export const useSidebarState = () => { selectedLocationId: state.selectedLocationId, selectedDocPath: state.selectedDocPath, documents: state.documents, + directories: state.directories, isLoadingLocations: state.isLoadingLocations, isLoadingDocuments: state.isLoadingDocuments, refreshingLocationId: state.refreshingLocationId, @@ -380,6 +383,7 @@ export const useSidebarState = () => { setFilterText: state.setSidebarFilter, selectLocation: state.setSelectedLocation, setDocuments: state.setDocuments, + setDirectories: state.setDirectories, setExternalDropTarget: state.setExternalDropTarget, })), ); @@ -389,6 +393,7 @@ export const useSidebarState = () => { selectedLocationId: workspaceState.selectedLocationId, selectedDocPath: workspaceState.selectedDocPath, documents: workspaceState.documents, + directories: workspaceState.directories, isLoading: workspaceState.isLoadingLocations || workspaceState.isLoadingDocuments, refreshingLocationId: workspaceState.refreshingLocationId, sidebarRefreshReason: workspaceState.sidebarRefreshReason, @@ -397,6 +402,7 @@ export const useSidebarState = () => { setFilterText: workspaceState.setFilterText, selectLocation: workspaceState.selectLocation, setDocuments: workspaceState.setDocuments, + setDirectories: workspaceState.setDirectories, toggleSidebarCollapsed: layoutState.toggleSidebarCollapsed, filenameVisibility: layoutState.filenameVisibility, setExternalDropTarget: workspaceState.setExternalDropTarget, diff --git a/src/state/stores/workspace.ts b/src/state/stores/workspace.ts index 761bc9c..1785175 100644 --- a/src/state/stores/workspace.ts +++ b/src/state/stores/workspace.ts @@ -24,6 +24,7 @@ export const getInitialWorkspaceLocationsState = (): WorkspaceLocationsState => export const getInitialWorkspaceDocumentsState = (): WorkspaceDocumentsState => ({ selectedDocPath: undefined, documents: [], + directories: [], isLoadingDocuments: false, refreshingLocationId: undefined, sidebarRefreshReason: null, @@ -48,6 +49,7 @@ export const useWorkspaceStore = create()((set) => ({ setSelectedDocPath: (path) => set({ selectedDocPath: path }), setDocuments: (documents) => set({ documents }), + setDirectories: (directories) => set({ directories }), setLoadingDocuments: (value) => set({ isLoadingDocuments: value }), setSidebarRefreshState: (locationId, reason: SidebarRefreshReason | null = null) => set({ refreshingLocationId: locationId, sidebarRefreshReason: locationId === undefined ? null : reason }), diff --git a/src/state/types.ts b/src/state/types.ts index 96146ed..f3c0005 100644 --- a/src/state/types.ts +++ b/src/state/types.ts @@ -114,6 +114,7 @@ type MoveDialogState = { locationId: number; relPath: string }; export type WorkspaceDocumentsState = { selectedDocPath?: string; documents: DocMeta[]; + directories: string[]; isLoadingDocuments: boolean; refreshingLocationId?: number; sidebarRefreshReason: SidebarRefreshReason | null; @@ -131,6 +132,7 @@ export type WorkspaceLocationsActions = { export type WorkspaceDocumentsActions = { setSelectedDocPath: (path?: string) => void; setDocuments: (documents: DocMeta[]) => void; + setDirectories: (directories: string[]) => void; setLoadingDocuments: (value: boolean) => void; setSidebarRefreshState: (locationId?: number, reason?: SidebarRefreshReason | null) => void; setExternalDropTarget: (locationId?: number) => void; diff --git a/tsconfig.json b/tsconfig.json index 958777d..cdb0ced 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,7 +26,9 @@ "$state/*": ["./state/*"], "$hooks/*": ["./hooks/*"], "$utils/*": ["./utils/*"], - "$constants": ["./constants.ts"] + "$constants": ["./constants.ts"], + "$dnd": ["./dnd/index.ts"], + "$dnd/*": ["./dnd/*"] } }, "include": ["src"], diff --git a/vite.config.ts b/vite.config.ts index 1502c79..17b9dfb 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -43,6 +43,7 @@ export default defineConfig({ "$hooks": resolveFromRoot("./src/hooks"), "$utils": resolveFromRoot("./src/utils"), "$constants": resolveFromRoot("./src/constants.ts"), + "$dnd": resolveFromRoot("./src/dnd"), }, }, });