From c64c76259825c331f468c04e24e3b2d7d4d5f813 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Wed, 25 Feb 2026 09:06:41 -0600 Subject: [PATCH] feat: new document creation from sidebar and toolbar * draft path generation and recovery --- crates/store/src/lib.rs | 94 +++++++++++++ src-tauri/src/commands.rs | 30 +++++ src-tauri/src/lib.rs | 2 + src/App.tsx | 105 ++++++++++++++- src/__tests__/WorkspacePanel.test.tsx | 1 + src/__tests__/useEditor.test.ts | 34 +++++ src/components/DocumentTabs/DocumentTabs.tsx | 45 ++++++- src/components/Sidebar/EmptyDocuments.tsx | 11 +- src/components/Sidebar/RemoveButton.tsx | 50 ++++--- src/components/Sidebar/Sidebar.tsx | 30 ++++- .../Sidebar/SidebarLocationItem.tsx | 124 +++++++++++++----- src/components/Sidebar/TreeItem.tsx | 17 +-- src/components/Toolbar.tsx | 26 +++- src/components/layout/WorkspacePanel.tsx | 15 ++- src/hooks/useEditor.ts | 40 +++++- src/hooks/useWorkspaceController.ts | 17 +++ src/ports.ts | 31 +++++ 17 files changed, 592 insertions(+), 80 deletions(-) diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index a129ca7..0a516f5 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -23,6 +23,7 @@ pub use settings::{CaptureDocRef, CaptureMode, FocusDimmingMode, GlobalCaptureSe const UI_LAYOUT_SETTINGS_KEY: &str = "ui_layout"; const STYLE_CHECK_SETTINGS_KEY: &str = "style_check"; const GLOBAL_CAPTURE_SETTINGS_KEY: &str = "global_capture"; +const LAST_OPEN_DOC_SETTINGS_KEY: &str = "last_open_doc"; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct StyleCheckPattern { @@ -312,6 +313,69 @@ impl Store { Ok(()) } + pub fn last_open_doc_get(&self) -> Result, AppError> { + let conn = self + .conn + .lock() + .map_err(|_| AppError::new(ErrorCode::Io, "Failed to lock database connection"))?; + + let maybe_value = conn + .query_row( + "SELECT value FROM app_settings WHERE key = ?1", + params![LAST_OPEN_DOC_SETTINGS_KEY], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|e| AppError::io(format!("Failed to query last open doc setting: {}", e)))?; + + match maybe_value { + Some(value) => serde_json::from_str::(&value).map(Some).map_err(|e| { + AppError::new( + ErrorCode::Parse, + format!("Failed to parse persisted last open doc setting: {}", e), + ) + }), + None => Ok(None), + } + } + + pub fn last_open_doc_set(&self, doc_ref: Option<&CaptureDocRef>) -> Result<(), AppError> { + let conn = self + .conn + .lock() + .map_err(|_| AppError::new(ErrorCode::Io, "Failed to lock database connection"))?; + + if let Some(doc_ref) = doc_ref { + let payload = serde_json::to_string(doc_ref).map_err(|e| { + AppError::new( + ErrorCode::Parse, + format!("Failed to serialize last open doc setting: {}", e), + ) + })?; + let updated_at = Utc::now().to_rfc3339(); + + conn.execute( + "INSERT INTO app_settings (key, value, updated_at) + VALUES (?1, ?2, ?3) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = excluded.updated_at", + params![LAST_OPEN_DOC_SETTINGS_KEY, payload, updated_at], + ) + .map_err(|e| AppError::io(format!("Failed to persist last open doc setting: {}", e)))?; + + return Ok(()); + } + + conn.execute( + "DELETE FROM app_settings WHERE key = ?1", + params![LAST_OPEN_DOC_SETTINGS_KEY], + ) + .map_err(|e| AppError::io(format!("Failed to clear last open doc setting: {}", e)))?; + + Ok(()) + } + /// Adds a new location pub fn location_add(&self, name: String, root_path: PathBuf) -> Result { let path_str = root_path.to_string_lossy().to_string(); @@ -1607,4 +1671,34 @@ mod tests { assert!(loaded.close_after_save); assert!(loaded.show_tray_icon); } + + #[test] + fn test_last_open_doc_defaults_to_none() { + let (store, _temp) = create_test_store(); + let loaded = store.last_open_doc_get().unwrap(); + assert!(loaded.is_none()); + } + + #[test] + fn test_last_open_doc_round_trip() { + let (store, _temp) = create_test_store(); + let doc_ref = CaptureDocRef { location_id: 5, rel_path: "notes/today.md".to_string() }; + + store.last_open_doc_set(Some(&doc_ref)).unwrap(); + let loaded = store.last_open_doc_get().unwrap(); + + assert_eq!(loaded, Some(doc_ref)); + } + + #[test] + fn test_last_open_doc_can_be_cleared() { + let (store, _temp) = create_test_store(); + let doc_ref = CaptureDocRef { location_id: 9, rel_path: "draft.md".to_string() }; + + store.last_open_doc_set(Some(&doc_ref)).unwrap(); + store.last_open_doc_set(None).unwrap(); + let loaded = store.last_open_doc_get().unwrap(); + + assert!(loaded.is_none()); + } } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index a40f85f..b1b8ea8 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -230,6 +230,36 @@ pub fn ui_layout_set(state: State<'_, AppState>, settings: UiLayoutSettings) -> } } +#[tauri::command] +pub fn session_last_doc_get( + state: State<'_, AppState>, +) -> Result>, ()> { + tracing::debug!("Loading last opened document session state"); + + match state.store.last_open_doc_get() { + Ok(doc_ref) => Ok(CommandResult::ok(doc_ref)), + Err(e) => { + tracing::error!("Failed to load last opened document session state: {}", e); + Ok(CommandResult::err(e)) + } + } +} + +#[tauri::command] +pub fn session_last_doc_set( + state: State<'_, AppState>, doc_ref: Option, +) -> Result, ()> { + tracing::debug!("Persisting last opened document session state"); + + match state.store.last_open_doc_set(doc_ref.as_ref()) { + Ok(()) => Ok(CommandResult::ok(true)), + Err(e) => { + tracing::error!("Failed to persist last opened document session state: {}", e); + Ok(CommandResult::err(e)) + } + } +} + /// Reconciles locations on startup and emits events for any issues pub fn reconcile_locations(app: &AppHandle) -> Result<(), AppError> { tracing::info!("Starting location reconciliation"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d459052..b23b051 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -73,6 +73,8 @@ pub fn run() { cmd::markdown_render_for_pdf, cmd::ui_layout_get, cmd::ui_layout_set, + cmd::session_last_doc_get, + cmd::session_last_doc_set, cmd::style_check_get, cmd::style_check_set, cmd::global_capture_get, diff --git a/src/App.tsx b/src/App.tsx index fd47571..7bb31e0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,10 +2,13 @@ import { Button } from "$components/Button"; import { logger } from "$logger"; import type { PdfExportOptions, PdfRenderResult } from "$pdf/types"; import { + docExists, globalCaptureGet, globalCaptureSet, renderMarkdownForPdf, runCmd, + sessionLastDocGet, + sessionLastDocSet, styleCheckGet, styleCheckSet, uiLayoutGet, @@ -154,6 +157,16 @@ function App() { editorDispatch({ type: "SaveRequested" }); }, [editorDispatch, editorModel.docRef, workspace]); + const handleNewDocument = useCallback((locationId?: number) => { + const draftRef = workspace.handleCreateNewDocument(locationId); + if (!draftRef) { + logger.warn("Cannot create a new document without a selected location."); + return; + } + + editorDispatch({ type: "NewDraftCreated", docRef: draftRef }); + }, [editorDispatch, workspace]); + const handleEditorChange = useCallback((text: string) => { editorDispatch({ type: "EditorChanged", text }); }, [editorDispatch]); @@ -228,7 +241,8 @@ function App() { }, [layoutChrome, isTyping]); const sidebarProps = useMemo( - () => pick(workspace, ["handleAddLocation", "handleRemoveLocation", "handleSelectDocument"]), + () => + pick(workspace, ["handleAddLocation", "handleRemoveLocation", "handleSelectDocument", "handleCreateNewDocument"]), [workspace], ); @@ -236,19 +250,102 @@ function App() { () => ({ saveStatus: editorModel.saveStatus, onSave: handleSave, + onNewDocument: handleNewDocument, + isNewDocumentDisabled: workspace.locations.length === 0, onExportPdf: handleOpenPdfExport, isExportingPdf: isExportingPdf, isPdfExportDisabled: !activeTab, onOpenSettings: handleOpenSettings, }), - [editorModel.saveStatus, handleOpenPdfExport, isExportingPdf, activeTab, handleSave, handleOpenSettings], + [ + editorModel.saveStatus, + handleOpenPdfExport, + isExportingPdf, + activeTab, + handleNewDocument, + workspace.locations.length, + handleSave, + handleOpenSettings, + ], ); const tabProps = useMemo( - () => pick(workspace, ["tabs", "activeTabId", "handleSelectTab", "handleCloseTab", "handleReorderTabs"]), - [workspace], + () => ({ + ...pick(workspace, ["tabs", "activeTabId", "handleSelectTab", "handleCloseTab", "handleReorderTabs"]), + onNewDocument: workspace.locations.length > 0 ? handleNewDocument : void 0, + }), + [workspace, handleNewDocument], ); + const activeDocRef = useMemo(() => activeTab?.docRef ?? null, [activeTab]); + + const startupDocumentReadyRef = useRef(false); + const startupDocumentRestoredRef = useRef(false); + + useEffect(() => { + if (startupDocumentReadyRef.current) { + return; + } + + if (workspace.isSidebarLoading || workspace.locations.length === 0 || workspace.tabs.length > 0) { + return; + } + + startupDocumentReadyRef.current = true; + + const completeStartupRestore = () => { + startupDocumentRestoredRef.current = true; + }; + + const fallbackToBlankDraft = () => { + completeStartupRestore(); + handleNewDocument(workspace.selectedLocationId ?? workspace.locations[0]?.id); + }; + + void runCmd(sessionLastDocGet((docRef) => { + if (!docRef) { + fallbackToBlankDraft(); + return; + } + + const locationExists = workspace.locations.some((location) => location.id === docRef.location_id); + if (!locationExists) { + fallbackToBlankDraft(); + return; + } + + void runCmd(docExists(docRef.location_id, docRef.rel_path, (exists) => { + if (exists) { + completeStartupRestore(); + workspace.handleSelectDocument(docRef.location_id, docRef.rel_path); + return; + } + + fallbackToBlankDraft(); + }, () => { + fallbackToBlankDraft(); + })); + }, () => { + fallbackToBlankDraft(); + })); + }, [ + workspace, + workspace.isSidebarLoading, + workspace.locations, + workspace.selectedLocationId, + workspace.tabs.length, + workspace.handleSelectDocument, + handleNewDocument, + ]); + + useEffect(() => { + if (!startupDocumentRestoredRef.current) { + return; + } + + void runCmd(sessionLastDocSet(activeDocRef, () => {}, () => {})); + }, [activeDocRef]); + const handleEditorChangeWithTyping = useCallback((text: string) => { handleEditorChange(text); handleTypingActivity(); diff --git a/src/__tests__/WorkspacePanel.test.tsx b/src/__tests__/WorkspacePanel.test.tsx index 4760ae7..d72d5d2 100644 --- a/src/__tests__/WorkspacePanel.test.tsx +++ b/src/__tests__/WorkspacePanel.test.tsx @@ -131,6 +131,7 @@ const createWorkspacePanelProps = (overrides: WorkspacePanelPropOverrides = {}): handleAddLocation: vi.fn(), handleRemoveLocation: vi.fn(), handleSelectDocument: vi.fn(), + handleCreateNewDocument: vi.fn(), ...overrides.sidebar, }, toolbar: { saveStatus: "Idle", onSave: vi.fn(), onOpenSettings: vi.fn(), ...overrides.toolbar }, diff --git a/src/__tests__/useEditor.test.ts b/src/__tests__/useEditor.test.ts index f240351..01dabca 100644 --- a/src/__tests__/useEditor.test.ts +++ b/src/__tests__/useEditor.test.ts @@ -347,4 +347,38 @@ describe(updateEditor, () => { expect(newModel.saveStatus).toBe("Dirty"); expect(cmd.type).toBe("None"); }); + + it("should create a blank draft document", () => { + const model = { ...initialEditorModel, text: "Previous", cursorLine: 4, cursorColumn: 12 }; + + const [newModel, cmd] = updateEditor(model, { + type: "NewDraftCreated", + docRef: { location_id: 2, rel_path: "Untitled.md" }, + }); + + expect(newModel.docRef).toStrictEqual({ location_id: 2, rel_path: "Untitled.md" }); + expect(newModel.text).toBe(""); + expect(newModel.cursorLine).toBe(1); + expect(newModel.cursorColumn).toBe(0); + expect(newModel.saveStatus).toBe("Dirty"); + expect(cmd.type).toBe("None"); + }); + + it("should recover missing generated drafts as blank unsaved docs", () => { + const model = { ...initialEditorModel, isLoading: true, text: "Old text" }; + + const [newModel, cmd] = updateEditor(model, { + type: "DocOpenFinished", + success: false, + error: { code: "NOT_FOUND", message: "Missing" }, + docRef: { location_id: 1, rel_path: "untitled_2026_02_24.md" }, + }); + + expect(newModel.docRef).toStrictEqual({ location_id: 1, rel_path: "untitled_2026_02_24.md" }); + expect(newModel.text).toBe(""); + expect(newModel.saveStatus).toBe("Dirty"); + expect(newModel.error).toBeNull(); + expect(newModel.isLoading).toBeFalsy(); + expect(cmd.type).toBe("None"); + }); }); diff --git a/src/components/DocumentTabs/DocumentTabs.tsx b/src/components/DocumentTabs/DocumentTabs.tsx index abd78bd..a7975d5 100644 --- a/src/components/DocumentTabs/DocumentTabs.tsx +++ b/src/components/DocumentTabs/DocumentTabs.tsx @@ -1,19 +1,56 @@ import { Button } from "$components/Button"; import { useViewportTier } from "$hooks/useViewportTier"; +import { PlusIcon } from "$icons"; import type { Tab } from "$types"; +import { motion } from "motion/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { DocumentTab } from "./DocumentTab"; +const EMPTY_NEW_DOC_INITIAL = { opacity: 0, y: -4 }; +const EMPTY_NEW_DOC_ANIMATE = { opacity: 1, y: 0 }; +const EMPTY_NEW_DOC_TRANSITION = { duration: 0.18 }; + +const NewButton = ({ onNewDocument, hasTabs }: { onNewDocument?: () => void; hasTabs: boolean }) => { + if (onNewDocument && hasTabs) { + return ( +
+ +
+ ); + } + + if (onNewDocument) { + return ( + + + + ); + } + + return null; +}; + export type DocumentTabsProps = { tabs: Tab[]; activeTabId: string | null; handleSelectTab: (tabId: string) => void; handleCloseTab: (tabId: string) => void; handleReorderTabs?: (tabs: Tab[]) => void; + onNewDocument?: () => void; }; export function DocumentTabs( - { tabs, activeTabId, handleSelectTab, handleCloseTab, handleReorderTabs }: DocumentTabsProps, + { tabs, activeTabId, handleSelectTab, handleCloseTab, handleReorderTabs, onNewDocument }: DocumentTabsProps, ) { const { viewportWidth, isCompact, isNarrow } = useViewportTier(); const [draggingTab, setDraggingTab] = useState(null); @@ -95,8 +132,9 @@ export function DocumentTabs( if (tabs.length === 0) { return ( -
- No documents open +
+ No documents open +
); } @@ -121,6 +159,7 @@ export function DocumentTabs( onCloseTab={handleCloseTab} compact={compactTabs} /> ))} + {contextMenu && (
( +import { Button } from "$components/Button"; + +export const EmptyDocuments = ( + { filterText, onCreateDocument }: { filterText?: string; onCreateDocument?: () => void }, +) => (
- {filterText ? "No matching documents" : "No documents found"} +

{filterText ? "No matching documents" : "No documents found"}

+ {!filterText && onCreateDocument && ( + + )}
); diff --git a/src/components/Sidebar/RemoveButton.tsx b/src/components/Sidebar/RemoveButton.tsx index 85f2ccf..565df82 100644 --- a/src/components/Sidebar/RemoveButton.tsx +++ b/src/components/Sidebar/RemoveButton.tsx @@ -1,6 +1,12 @@ import { Button } from "$components/Button"; import { TrashIcon } from "$icons"; -import type { MouseEventHandler } from "react"; +import { AnimatePresence, motion } from "motion/react"; +import { type MouseEventHandler, useCallback } 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 }; export function RemoveButton( { isMenuOpen, handleRemoveClick, handleMouseEnter, handleMouseLeave }: { @@ -10,20 +16,32 @@ export function RemoveButton( handleMouseLeave: MouseEventHandler; }, ) { - if (isMenuOpen) { - return ( -
- -
- ); - } + const Inner = useCallback( + () => ( + + ), + [handleRemoveClick, handleMouseEnter, handleMouseLeave], + ); - return null; + return ( + + {isMenuOpen && ( + + + + )} + + ); } diff --git a/src/components/Sidebar/Sidebar.tsx b/src/components/Sidebar/Sidebar.tsx index 3a84293..2e9b7c6 100644 --- a/src/components/Sidebar/Sidebar.tsx +++ b/src/components/Sidebar/Sidebar.tsx @@ -2,7 +2,7 @@ import { Button } from "$components/Button"; import { CollapseIcon } from "$icons"; import { useSidebarState } from "$state/panel-selectors"; import type { ChangeEventHandler, MouseEventHandler } from "react"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { AddButton } from "./AddButton"; import { EmptyLocations } from "./EmptyLocations"; import { SearchInput } from "./SearchInput"; @@ -13,6 +13,7 @@ export type SidebarProps = { handleAddLocation: () => void; handleRemoveLocation: (locationId: number) => void; handleSelectDocument: (locationId: number, path: string) => void; + handleCreateNewDocument: (locationId?: number) => void; }; type SidebarActionsProps = { @@ -44,7 +45,9 @@ const SidebarActions = (
); -export function Sidebar({ handleAddLocation, handleRemoveLocation, handleSelectDocument }: SidebarProps) { +export function Sidebar( + { handleAddLocation, handleRemoveLocation, handleSelectDocument, handleCreateNewDocument }: SidebarProps, +) { const { locations, selectedLocationId, @@ -59,6 +62,28 @@ export function Sidebar({ handleAddLocation, handleRemoveLocation, handleSelectD const [expandedLocations, setExpandedLocations] = useState>(() => new Set(locations.map((l) => l.id))); const [showLocationMenu, setShowLocationMenu] = useState(null); + 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]); + const locationDocuments = useMemo( () => (selectedLocationId ? documents.filter((doc) => doc.location_id === selectedLocationId) : []), [documents, selectedLocationId], @@ -124,6 +149,7 @@ export function Sidebar({ handleAddLocation, handleRemoveLocation, handleSelectD onToggle={toggleLocation} onRemove={handleRemoveLocation} onSelectDocument={handleSelectDocument} + onCreateDocument={handleCreateNewDocument} setShowLocationMenu={setShowLocationMenu} isMenuOpen={showLocationMenu === location.id} documents={filteredDocuments} diff --git a/src/components/Sidebar/SidebarLocationItem.tsx b/src/components/Sidebar/SidebarLocationItem.tsx index 5dd338f..ec95a3c 100644 --- a/src/components/Sidebar/SidebarLocationItem.tsx +++ b/src/components/Sidebar/SidebarLocationItem.tsx @@ -1,12 +1,76 @@ import { Button } from "$components/Button"; -import { FolderIcon, MoreVerticalIcon } from "$icons"; +import { FolderIcon, MoreVerticalIcon, PlusIcon } from "$icons"; import { DocMeta, LocationDescriptor } from "$types"; -import { MouseEventHandler, useCallback, useMemo } from "react"; +import type { Dispatch, MouseEventHandler, SetStateAction } from "react"; +import { useCallback, useMemo } from "react"; import { DocumentItem } from "./DocumentItem"; import { EmptyDocuments } from "./EmptyDocuments"; import { RemoveButton } from "./RemoveButton"; import { TreeItem } from "./TreeItem"; +const folderIcon = { Component: FolderIcon, size: "md" as const }; + +const NewDocumentButton = ({ onClick }: { onClick: () => void }) => ( +
+ +
+); + +const FolderItem = ( + { name, isSelected, selectedDocPath, isExpanded, onItemClick, onToggleClick, actionProps }: { + name: string; + isSelected: boolean; + selectedDocPath?: string; + isExpanded: boolean; + onItemClick: () => void; + onToggleClick: () => void; + actionProps: LocationActionProps; + }, +) => ( +
+ + + +
+); + +type LocationActionProps = { + isMenuOpen: boolean; + handleMenuClick: MouseEventHandler; + handleRemoveClick: () => void; + handleMouseEnter: MouseEventHandler; + handleMouseLeave: MouseEventHandler; +}; + +const LocationActions = ( + { isMenuOpen, handleMenuClick, handleRemoveClick, handleMouseEnter, handleMouseLeave }: LocationActionProps, +) => ( +
+ + +
+); + type SidebarLocationItemProps = { location: LocationDescriptor; isSelected: boolean; @@ -16,7 +80,8 @@ type SidebarLocationItemProps = { onToggle: (id: number) => void; onRemove: (id: number) => void; onSelectDocument: (id: number, path: string) => void; - setShowLocationMenu: (id: number | null) => void; + onCreateDocument: (locationId?: number) => void; + setShowLocationMenu: Dispatch>; documents: DocMeta[]; filterText: string; isMenuOpen: boolean; @@ -32,6 +97,7 @@ export function SidebarLocationItem( onToggle, onRemove, onSelectDocument, + onCreateDocument, setShowLocationMenu, documents, filterText, @@ -43,8 +109,9 @@ export function SidebarLocationItem( setShowLocationMenu(null); }, [location.id, onRemove, setShowLocationMenu]); - const handleMenuClick = useCallback(() => { - setShowLocationMenu(location.id); + const handleMenuClick: MouseEventHandler = useCallback((event) => { + event.stopPropagation(); + setShowLocationMenu((current) => current === location.id ? null : location.id); }, [location.id, setShowLocationMenu]); const handleMouseEnter: MouseEventHandler = useCallback((e) => { @@ -65,42 +132,31 @@ export function SidebarLocationItem( onToggle(location.id); }, [location.id, onToggle]); - const folderIcon = useMemo(() => ({ Component: FolderIcon, size: "md" as const }), []); - - const LocationActions = useCallback(() => ( -
- - -
- ), [isMenuOpen, handleMenuClick, handleRemoveClick, handleMouseEnter, handleMouseLeave]); + const handleCreateDocumentClick = useCallback(() => { + onCreateDocument(location.id); + }, [location.id, onCreateDocument]); + + const actionProps = useMemo( + () => ({ isMenuOpen, handleMenuClick, handleRemoveClick, handleMouseEnter, handleMouseLeave }), + [isMenuOpen, handleMenuClick, handleRemoveClick, handleMouseEnter, handleMouseLeave], + ); return (
-
- -
+ {isExpanded && isSelected && (
+ {documents.length === 0 - ? + ? : (documents.map((doc) => ( void; onToggle?: () => void; - Actions?: React.ComponentType; + children?: React.ReactNode; }; export function TreeItem( - { - icon, - label, - isSelected = false, - isExpanded = false, - hasChildren = false, - level = 0, - onClick, - onToggle, - Actions = () => null, - }: TreeItemProps, + { icon, label, isSelected = false, isExpanded = false, hasChildren = false, level = 0, onClick, onToggle, children }: + TreeItemProps, ) { const paddingLeft = level * 16 + 12; @@ -97,7 +88,7 @@ export function TreeItem( {label} - + {children}
); } diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 04e098c..13028d5 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -7,6 +7,7 @@ import { FocusIcon, IconProps, type IconSize, + PlusIcon, RefreshIcon, SaveIcon, SettingsIcon, @@ -21,6 +22,8 @@ import { Tooltip } from "./Tooltip"; export type ToolbarProps = { saveStatus: SaveStatus; onSave: () => void; + onNewDocument?: () => void; + isNewDocumentDisabled?: boolean; onOpenSettings: () => void; onExportPdf?: () => void; isExportingPdf?: boolean; @@ -122,8 +125,17 @@ function SaveStatusIndicator({ status, compact = false }: { status: SaveStatus; } export function Toolbar( - { saveStatus, onSave, onOpenSettings, onExportPdf, isExportingPdf = false, isPdfExportDisabled = false, onRefresh }: - ToolbarProps, + { + saveStatus, + onSave, + onNewDocument, + isNewDocumentDisabled = false, + onOpenSettings, + onExportPdf, + isExportingPdf = false, + isPdfExportDisabled = false, + onRefresh, + }: ToolbarProps, ) { const { isSplitView, isFocusMode, isPreviewVisible, toggleSplitView, toggleFocusMode, togglePreviewVisible } = useToolbarState(); @@ -131,6 +143,7 @@ export function Toolbar( const icons: Record; size: IconSize }> = useMemo( () => ({ save: { Component: SaveIcon, size: "sm" }, + newDoc: { Component: PlusIcon, size: "sm" }, refresh: { Component: RefreshIcon, size: "sm" }, splitView: { Component: SplitViewIcon, size: "sm" }, eye: { Component: EyeIcon, size: "sm" }, @@ -155,6 +168,15 @@ export function Toolbar( disabled={saveStatus === "Saved" || saveStatus === "Saving"} shortcut="Ctrl+S" iconOnly={isCompact} /> + {onNewDocument && ( + + )} {onRefresh && !hideRefresh && ( diff --git a/src/components/layout/WorkspacePanel.tsx b/src/components/layout/WorkspacePanel.tsx index 5fc933e..4de814c 100644 --- a/src/components/layout/WorkspacePanel.tsx +++ b/src/components/layout/WorkspacePanel.tsx @@ -24,10 +24,21 @@ export type WorkspacePreviewProps = Pick; export type CalmUiVisibility = { sidebar: boolean; statusBar: boolean; tabBar: boolean }; export type WorkspacePanelProps = { - sidebar: Pick; + sidebar: Pick< + SidebarProps, + "handleAddLocation" | "handleRemoveLocation" | "handleSelectDocument" | "handleCreateNewDocument" + >; toolbar: Pick< ToolbarProps, - "saveStatus" | "onSave" | "onOpenSettings" | "onExportPdf" | "isExportingPdf" | "isPdfExportDisabled" | "onRefresh" + | "saveStatus" + | "onSave" + | "onNewDocument" + | "isNewDocumentDisabled" + | "onOpenSettings" + | "onExportPdf" + | "isExportingPdf" + | "isPdfExportDisabled" + | "onRefresh" >; tabs: DocumentTabsProps; editor: WorkspaceEditorProps; diff --git a/src/hooks/useEditor.ts b/src/hooks/useEditor.ts index b615aa2..a3d5801 100644 --- a/src/hooks/useEditor.ts +++ b/src/hooks/useEditor.ts @@ -32,9 +32,10 @@ export type EditorMsg = | { type: "SaveRequested" } | { type: "SaveFinished"; success: boolean; result?: SaveResult; error?: AppError } | { type: "DraftDocInitialized"; docRef: DocRef } + | { type: "NewDraftCreated"; docRef: DocRef } | { type: "DocOpened"; doc: DocContent } | { type: "OpenDocRequested"; docRef: DocRef } - | { type: "DocOpenFinished"; success: boolean; error?: AppError } + | { type: "DocOpenFinished"; success: boolean; error?: AppError; docRef?: DocRef } | { type: "CursorMoved"; line: number; column: number } | { type: "SelectionChanged"; from: number; to: number | null }; @@ -42,6 +43,11 @@ function isEditorMsg(value: unknown): value is EditorMsg { return typeof value === "object" && value !== null && "type" in value && typeof value.type === "string"; } +function isGeneratedDraftPath(path: string): boolean { + const filename = path.split("/").pop() ?? ""; + return /^untitled_\d{4}_\d{2}_\d{2}(?:_\d+)?\.md$/i.test(filename); +} + export function updateEditor(model: EditorModel, msg: EditorMsg): [EditorModel, Cmd] { switch (msg.type) { case "EditorChanged": { @@ -75,6 +81,21 @@ export function updateEditor(model: EditorModel, msg: EditorMsg): [EditorModel, return [{ ...model, docRef: msg.docRef, saveStatus: "Dirty", error: null }, none]; } + case "NewDraftCreated": { + return [{ + ...model, + docRef: msg.docRef, + text: "", + saveStatus: "Dirty", + cursorLine: 1, + cursorColumn: 0, + selectionFrom: null, + selectionTo: null, + isLoading: false, + error: null, + }, none]; + } + case "OpenDocRequested": { return [ { ...model, isLoading: true, error: null }, @@ -82,7 +103,7 @@ export function updateEditor(model: EditorModel, msg: EditorMsg): [EditorModel, msg.docRef.location_id, msg.docRef.rel_path, (doc: DocContent) => ({ type: "DocOpened", doc }), - (error) => ({ type: "DocOpenFinished", success: false, error }), + (error) => ({ type: "DocOpenFinished", success: false, error, docRef: msg.docRef }), ), ]; } @@ -99,6 +120,21 @@ export function updateEditor(model: EditorModel, msg: EditorMsg): [EditorModel, } case "DocOpenFinished": { + if (!msg.success && msg.error?.code === "NOT_FOUND" && msg.docRef && isGeneratedDraftPath(msg.docRef.rel_path)) { + return [{ + ...model, + docRef: msg.docRef, + text: "", + saveStatus: "Dirty", + cursorLine: 1, + cursorColumn: 0, + selectionFrom: null, + selectionTo: null, + isLoading: false, + error: null, + }, none]; + } + return [{ ...model, isLoading: false, error: msg.error ?? null }, none]; } diff --git a/src/hooks/useWorkspaceController.ts b/src/hooks/useWorkspaceController.ts index de493cf..5474d9c 100644 --- a/src/hooks/useWorkspaceController.ts +++ b/src/hooks/useWorkspaceController.ts @@ -1,6 +1,7 @@ import { logger } from "$logger"; import { locationAddViaDialog, locationRemove, runCmd } from "$ports"; import type { DocRef, Tab } from "$types"; +import { buildDraftRelPath, getDraftTitle } from "$utils/paths"; import { useCallback, useMemo } from "react"; import { useAppStore, @@ -81,6 +82,21 @@ export function useWorkspaceController(openDoc: (docRef: DocRef) => void) { openDocumentTab(docRef, title); }, [openDocumentTab]); + const handleCreateNewDocument = useCallback((locationId?: number) => { + const state = useAppStore.getState(); + const targetLocationId = locationId ?? state.selectedLocationId ?? state.locations[0]?.id; + + if (!targetLocationId) { + logger.warn("Cannot create draft without a selected location."); + return null; + } + + const relPath = buildDraftRelPath(targetLocationId, state.documents, state.tabs); + const docRef: DocRef = { location_id: targetLocationId, rel_path: relPath }; + openDocumentTab(docRef, getDraftTitle(relPath)); + return docRef; + }, [openDocumentTab]); + return { locations, documents, @@ -101,5 +117,6 @@ export function useWorkspaceController(openDoc: (docRef: DocRef) => void) { handleCloseTab, handleReorderTabs, handleCreateDraftTab, + handleCreateNewDocument, }; } diff --git a/src/ports.ts b/src/ports.ts index 9768f80..67e82a6 100644 --- a/src/ports.ts +++ b/src/ports.ts @@ -293,6 +293,18 @@ function normalizeDocMeta(value: unknown): DocMeta { return { location_id: locationId, rel_path: relPath, title, updated_at: updatedAt, word_count: wordCount }; } +function normalizeDocRef(value: unknown): DocRef | null { + if (!isRecord(value)) { + return null; + } + + if (typeof value.location_id !== "number" || typeof value.rel_path !== "string") { + return null; + } + + return { location_id: value.location_id, rel_path: value.rel_path }; +} + function normalizeSearchHit(value: unknown): SearchHit { if (!isRecord(value)) { return { location_id: 0, rel_path: "", title: "Untitled", snippet: "", line: 1, column: 1, matches: [] }; @@ -441,6 +453,9 @@ function normalizeCommandValue(command: string, value: unknown): unknown { case "global_capture_submit": { return normalizeCaptureSubmitResult(value); } + case "session_last_doc_get": { + return normalizeDocRef(value); + } default: { return value; } @@ -620,6 +635,10 @@ export function docSave(...[locationId, relPath, text, onOk, onErr]: DocSavePara return invokeCmd("doc_save", { locationId, relPath, text }, onOk, onErr); } +export function docExists(...[locationId, relPath, onOk, onErr]: DocOpenParams): Cmd { + return invokeCmd("doc_exists", { locationId, relPath }, onOk, onErr); +} + export function searchDocuments(...[query, filters, limit, onOk, onErr]: SearchParams): Cmd { return invokeCmd("search", { query, filters, limit }, onOk, onErr); } @@ -644,6 +663,18 @@ export function uiLayoutSet(...[settings, onOk, onErr]: UiLayoutSetParams("ui_layout_set", { settings }, onOk, onErr); } +type SessionLastDocSetParams = Parameters< + (docRef: DocRef | null, onOk: SuccessCallback, onErr: ErrorCallback) => void +>; + +export function sessionLastDocGet(...[onOk, onErr]: LocParams): Cmd { + return invokeCmd("session_last_doc_get", {}, onOk, onErr); +} + +export function sessionLastDocSet(...[docRef, onOk, onErr]: SessionLastDocSetParams): Cmd { + return invokeCmd("session_last_doc_set", { docRef }, onOk, onErr); +} + type StyleCheckSetParams = Parameters< (settings: PersistedStyleCheckSettings, onOk: SuccessCallback, onErr: ErrorCallback) => void >; -- 2.51.2