diff --git a/CHANGELOG.md b/CHANGELOG.md index 58954f0..7f3925b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,21 @@ ## Unreleased +### 2026-03-01 + +- Show version in the UI. +- Drag & drop powered file browser + +### 2026-02-28 + +- Resizable markdown preview split view. +- (WIP) Project website. + ### 2026-02-27 +- Migrated session state management to Rust. + - Moved NLP algorithm logic from frontend to Tauri/Rust for better performance. + - Transitioned to event-driven filesystem updates. - Added PDF Preview on Export - Implemented file operations (move, rename, delete) with sidebar context menu support. - Added a draggable "Help" sheet and integrated diagnostics/style checking into routed sheets. diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 4ddb4fd..96d00ce 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1157,12 +1157,30 @@ pub async fn global_capture_submit( settings.append_target.clone() }; + let quick_note_inbox_dir: &str = if matches!( + mode, + writer_store::CaptureMode::QuickNote | writer_store::CaptureMode::WritingSession + ) { + if let Some(ref dest) = destination { + let requested_dir = dest.rel_path.trim(); + if requested_dir.is_empty() { + &settings.inbox_relative_dir + } else { + requested_dir + } + } else { + &settings.inbox_relative_dir + } + } else { + &settings.inbox_relative_dir + }; + match crate::capture::handle_capture_submit( &app, mode, text, target_location, - &settings.inbox_relative_dir, + quick_note_inbox_dir, &append_target, settings.close_after_save, ) diff --git a/src/__tests__/AppHeaderBar.test.tsx b/src/__tests__/AppHeaderBar.test.tsx index e3f59f4..7f4dae5 100644 --- a/src/__tests__/AppHeaderBar.test.tsx +++ b/src/__tests__/AppHeaderBar.test.tsx @@ -2,6 +2,7 @@ import { AppHeaderBar } from "$components/AppLayout/AppHeaderBar"; import { useRoutedSheet } from "$hooks/useRoutedSheet"; import { useViewportTier } from "$hooks/useViewportTier"; import { useAppHeaderBarState, useHelpSheetState } from "$state/selectors"; +import { formatShortcut } from "$utils/shortcuts"; import { fireEvent, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -39,7 +40,7 @@ describe("AppHeaderBar", () => { vi.mocked(useHelpSheetState).mockReturnValue({ isOpen: false, setOpen: setHelpSheetOpen, toggle: vi.fn() }); render(); - fireEvent.click(screen.getByTitle("Open help sheet (Cmd+/)")); + fireEvent.click(screen.getByTitle(`Open help sheet (${formatShortcut("Cmd+/")})`)); expect(setHelpSheetOpen).toHaveBeenCalledWith(true); }); diff --git a/src/__tests__/QuickCaptureForm.test.tsx b/src/__tests__/QuickCaptureForm.test.tsx index 81509b2..c31e910 100644 --- a/src/__tests__/QuickCaptureForm.test.tsx +++ b/src/__tests__/QuickCaptureForm.test.tsx @@ -2,6 +2,11 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { QuickCaptureForm } from "../components/QuickCapture/QuickCaptureForm"; +const SAVE_TARGETS = [ + { id: "1:inbox", locationId: 1, relPath: "inbox", label: "Workspace A / inbox" }, + { id: "1:inbox/projects", locationId: 1, relPath: "inbox/projects", label: "Workspace A / inbox/projects" }, +]; + describe("QuickCaptureForm", () => { it("submits on Enter in quick note mode", () => { const onSubmit = vi.fn(); @@ -66,4 +71,44 @@ describe("QuickCaptureForm", () => { expect(onClose).toHaveBeenCalledTimes(1); expect(onSubmit).not.toHaveBeenCalled(); }); + + it("chooses Save To destination from the dropdown menu", () => { + const onSubmit = vi.fn(); + const onClose = vi.fn(); + + render( + , + ); + + const textarea = screen.getByPlaceholderText("Type your note here..."); + fireEvent.change(textarea, { target: { value: "Capture this" } }); + + fireEvent.click(screen.getByLabelText("Choose save destination")); + fireEvent.click(screen.getByRole("menuitem", { name: "Save to Workspace A / inbox/projects" })); + fireEvent.click(screen.getByText("Save To")); + + expect(onSubmit).toHaveBeenCalledWith("Capture this", "QuickNote", { locationId: 1, relPath: "inbox/projects" }); + }); + + it("disables Save To selection in append mode", () => { + const onSubmit = vi.fn(); + const onClose = vi.fn(); + + render( + , + ); + + expect(screen.getByLabelText("Choose save destination")).toBeDisabled(); + }); }); diff --git a/src/__tests__/WorkspacePanel.test.tsx b/src/__tests__/WorkspacePanel.test.tsx index b186c0b..776a263 100644 --- a/src/__tests__/WorkspacePanel.test.tsx +++ b/src/__tests__/WorkspacePanel.test.tsx @@ -25,6 +25,7 @@ import type { WorkspacePanelModeStateReturn, WorkspacePanelSidebarStateReturn, } from "$state/selectors"; +import { formatShortcut } from "$utils/shortcuts"; import { act, fireEvent, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -253,7 +254,7 @@ describe("WorkspacePanel", () => { workspacePanelSidebarState: { sidebarCollapsed: false }, }); - fireEvent.click(screen.getByTitle("Hide sidebar (Ctrl+B)")); + fireEvent.click(screen.getByTitle(`Hide sidebar (${formatShortcut("Cmd+B")})`)); expect(onToggleSidebar).toHaveBeenCalledOnce(); const separator = screen.getByRole("separator", { name: "Resize sidebar" }); diff --git a/src/components/AppLayout/AppHeaderBar.tsx b/src/components/AppLayout/AppHeaderBar.tsx index f5923ee..487ef0d 100644 --- a/src/components/AppLayout/AppHeaderBar.tsx +++ b/src/components/AppLayout/AppHeaderBar.tsx @@ -5,6 +5,7 @@ import { useViewportTier } from "$hooks/useViewportTier"; import { CheckIcon, ChevronDownIcon, PenIcon, QuestionIcon, SearchIcon } from "$icons"; import { appVersionGet, runCmd } from "$ports"; import { useAppHeaderBarState, useHelpSheetState } from "$state/selectors"; +import { formatShortcut } from "$utils/shortcuts"; import { useCallback, useEffect, useMemo, useState } from "react"; const AppTitle = ({ hideTitle, version }: { hideTitle: boolean; version: string }) => ( @@ -56,6 +57,11 @@ function SearchRow( compactTabLabel, }: SearchRowProps, ) { + const searchShortcut = useMemo(() => formatShortcut("Cmd+Shift+F"), []); + const helpShortcut = useMemo(() => formatShortcut("Cmd+/"), []); + const toggleTabBarShortcut = useMemo(() => formatShortcut("Cmd+Shift+B"), []); + const toggleSidebarShortcut = useMemo(() => formatShortcut("Cmd+B"), []); + const statusbarId = useMemo(() => { if (compactTabLabel) { return { @@ -70,18 +76,18 @@ function SearchRow( }, [compactTabLabel, statusBarCollapsed]); const tabbarId = useMemo(() => { - const title = `${tabBarCollapsed ? "Show" : "Hide"} tab bar (Ctrl+Shift+B)`; + const title = `${tabBarCollapsed ? "Show" : "Hide"} tab bar (${toggleTabBarShortcut})`; if (compactTabLabel) { return { label: tabBarCollapsed ? "Show Tabs" : "Hide Tabs", title }; } return { label: tabBarCollapsed ? "Show Tab Bar" : "Hide Tab Bar", title }; - }, [compactTabLabel, tabBarCollapsed]); + }, [compactTabLabel, tabBarCollapsed, toggleTabBarShortcut]); const sidebarId = useMemo(() => { - const title = `${sidebarCollapsed ? "Show" : "Hide"} sidebar (Ctrl+B)`; + const title = `${sidebarCollapsed ? "Show" : "Hide"} sidebar (${toggleSidebarShortcut})`; const label = sidebarCollapsed ? "Show Sidebar" : "Hide Sidebar"; return { label, title }; - }, [sidebarCollapsed]); + }, [sidebarCollapsed, toggleSidebarShortcut]); return (
@@ -90,11 +96,11 @@ function SearchRow( className={`flex items-center gap-1.5 px-3 py-1.5 bg-field-01 border border-border-subtle rounded text-text-secondary text-[0.8125rem] cursor-pointer ${ iconOnly ? "w-8 h-8 px-0 justify-center" : "" }`} - title="Search (Ctrl+Shift+F)"> + title={`Search (${searchShortcut})`}> {iconOnly ? null : Search} {!iconOnly && showSearchShortcut && ( - Ctrl+Shift+F + {searchShortcut} )}
diff --git a/src/components/HelpSheet/ShortcutsTabContent.tsx b/src/components/HelpSheet/ShortcutsTabContent.tsx index f1c5883..3c9bf37 100644 --- a/src/components/HelpSheet/ShortcutsTabContent.tsx +++ b/src/components/HelpSheet/ShortcutsTabContent.tsx @@ -1,5 +1,6 @@ import { useShortcutsByCategory } from "$hooks/useShortcutRegistry"; import { KeyboardShortcut } from "$state/stores/shortcuts"; +import { formatKey } from "$utils/shortcuts"; import { cn } from "$utils/tw"; type ShortcutsTabContentProps = { className?: string }; @@ -58,50 +59,3 @@ export function ShortcutsTabContent({ className }: ShortcutsTabContentProps) { ); } - -function formatKey(key: string): string { - const isMac = typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.platform); - - switch (key.toLowerCase()) { - case "cmd": - case "command": - case "meta": - return isMac ? "⌘" : "Ctrl"; - case "ctrl": - case "control": - return isMac ? "⌃" : "Ctrl"; - case "alt": - case "option": - return isMac ? "⌥" : "Alt"; - case "shift": - return isMac ? "⇧" : "Shift"; - case "enter": - case "return": - return "↵"; - case "escape": - case "esc": - return "Esc"; - case "backspace": - return isMac ? "⌫" : "Backspace"; - case "delete": - return isMac ? "⌦" : "Del"; - case "tab": - return isMac ? "⇥" : "Tab"; - case "space": - return "Space"; - case "arrowup": - case "arrow-up": - return "↑"; - case "arrowdown": - case "arrow-down": - return "↓"; - case "arrowleft": - case "arrow-left": - return "←"; - case "arrowright": - case "arrow-right": - return "→"; - default: - return key.length === 1 ? key.toUpperCase() : key; - } -} diff --git a/src/components/QuickCapture/QuickCaptureApp.tsx b/src/components/QuickCapture/QuickCaptureApp.tsx index a6ed1d9..671ff1e 100644 --- a/src/components/QuickCapture/QuickCaptureApp.tsx +++ b/src/components/QuickCapture/QuickCaptureApp.tsx @@ -1,69 +1,189 @@ -import { globalCaptureGet, globalCaptureSubmit, runCmd } from "$ports"; -import type { CaptureMode, CaptureSubmitResult, GlobalCaptureSettings } from "$types"; +import { dirList, globalCaptureGet, globalCaptureSubmit, locationList, runCmd, uiLayoutGet } from "$ports"; +import type { AppTheme, CaptureMode, CaptureSubmitResult, GlobalCaptureSettings, LocationDescriptor } from "$types"; import type { AppError } from "$types"; import { f } from "$utils/serialize"; import { getCurrentWindow } from "@tauri-apps/api/window"; import * as logger from "@tauri-apps/plugin-log"; import { useCallback, useEffect, useRef, useState } from "react"; -import { QuickCaptureForm } from "./QuickCaptureForm"; +import { QuickCaptureForm, type QuickCaptureSaveTarget } from "./QuickCaptureForm"; + +const getPreferredTheme = (): AppTheme => { + if (typeof globalThis.matchMedia === "function") { + return globalThis.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"; + } + + return "dark"; +}; export function QuickCaptureApp() { const [settings, setSettings] = useState(null); + const [locations, setLocations] = useState([]); + const [saveTargets, setSaveTargets] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); + const [theme, setTheme] = useState(() => getPreferredTheme()); + const [reduceMotion, setReduceMotion] = useState(false); const windowRef = useRef(getCurrentWindow()); + const normalizeRelPath = useCallback((value: string): string => { + const normalized = value.replaceAll("\\", "/").replace(/^\.\/+/, "").replaceAll(/^\/+|\/+$/g, ""); + return normalized; + }, []); + + const listLocationDirectories = useCallback(async (locationId: number): Promise => { + let directories: string[] = []; + await runCmd(dirList(locationId, (loadedDirectories) => { + directories = loadedDirectories; + }, (err) => { + logger.warn(f("Failed to load quick capture directories", { locationId, err })); + directories = []; + })); + return directories; + }, []); + + useEffect(() => { + if (typeof globalThis.matchMedia !== "function") { + return; + } + + const mediaQuery = globalThis.matchMedia("(prefers-color-scheme: light)"); + const handleThemeChange = (event: MediaQueryListEvent) => { + setTheme(event.matches ? "light" : "dark"); + }; + + mediaQuery.addEventListener("change", handleThemeChange); + return () => { + mediaQuery.removeEventListener("change", handleThemeChange); + }; + }, []); + useEffect(() => { + let isCancelled = false; + const loadSettings = async () => { await runCmd(globalCaptureGet((loadedSettings) => { + if (isCancelled) { + return; + } setSettings(loadedSettings); setIsLoading(false); }, (err) => { + if (isCancelled) { + return; + } logger.error(f("Failed to load global capture settings", { err })); setError("Failed to load settings"); setIsLoading(false); })); + + await runCmd(locationList((loadedLocations) => { + if (isCancelled) { + return; + } + setLocations(loadedLocations); + }, (err) => { + if (isCancelled) { + return; + } + logger.warn(f("Failed to load locations for quick capture", { err })); + })); + + await runCmd(uiLayoutGet((layoutSettings) => { + if (isCancelled) { + return; + } + setReduceMotion(layoutSettings.reduce_motion); + }, (err) => { + logger.warn(f("Failed to load layout settings for quick capture", { err })); + })); }; - loadSettings(); + void loadSettings(); + return () => { + isCancelled = true; + }; }, []); - const handleSubmit = useCallback( - async (text: string, mode: CaptureMode, destination?: { locationId: number; relPath: string }) => { - if (!settings || isSubmitting) return; - - setIsSubmitting(true); - setError(null); - - await runCmd( - globalCaptureSubmit({ mode, text, destination, openMainAfterSave: false }, (result: CaptureSubmitResult) => { - setIsSubmitting(false); - - if (result.success) { - logger.info( - f("Capture submitted successfully", { savedTo: result.savedTo, locationId: result.locationId }), - ); - - if (result.shouldClose) { - windowRef.current.close().catch((err) => { - logger.error( - f("Failed to close window", { message: err instanceof Error ? err.message : String(err) }), - ); - }); - } - } else { - setError("Failed to save capture"); - } - }, (err: AppError) => { - setIsSubmitting(false); - logger.error(f("Capture submission failed", { err })); - setError(err.message || "Failed to save capture"); + useEffect(() => { + let isCancelled = false; + + const loadSaveTargets = async () => { + if (locations.length === 0) { + setSaveTargets([]); + return; + } + + const defaultInboxDir = normalizeRelPath(settings?.inboxRelativeDir ?? "inbox"); + const loadedTargets = await Promise.all( + locations.map(async (location) => { + const directories = await listLocationDirectories(location.id); + const normalizedDirectories = directories + .map((directory) => normalizeRelPath(directory)) + .filter((directory) => directory.length > 0); + const allDirectories = Array.from(new Set([defaultInboxDir, ...normalizedDirectories])) + .filter((directory) => directory.length > 0) + .toSorted((a, b) => a.localeCompare(b)); + + return allDirectories.map((directory) => ({ + id: `${location.id}:${directory}`, + locationId: location.id, + relPath: directory, + label: `${location.name} / ${directory}`, + } satisfies QuickCaptureSaveTarget)); }), ); - }, - [settings, isSubmitting], - ); + + if (isCancelled) { + return; + } + + setSaveTargets(loadedTargets.flat()); + }; + + void loadSaveTargets(); + return () => { + isCancelled = true; + }; + }, [listLocationDirectories, locations, normalizeRelPath, settings?.inboxRelativeDir]); + + const handleSubmitToDestination = useCallback(async ( + text: string, + mode: CaptureMode, + destination?: { locationId: number; relPath: string }, + ) => { + if (!settings || isSubmitting) return; + + setIsSubmitting(true); + setError(null); + const destinationPayload = mode === "Append" ? void 0 : destination; + + await runCmd( + globalCaptureSubmit( + { mode, text, destination: destinationPayload, openMainAfterSave: false }, + (result: CaptureSubmitResult) => { + setIsSubmitting(false); + + if (result.success) { + logger.info(f("Capture submitted successfully", { savedTo: result.savedTo, locationId: result.locationId })); + + if (result.shouldClose) { + windowRef.current.close().catch((err) => { + logger.error(f("Failed to close window", { message: err instanceof Error ? err.message : String(err) })); + }); + } + } else { + setError("Failed to save capture"); + } + }, + (err: AppError) => { + setIsSubmitting(false); + logger.error(f("Capture submission failed", { err })); + setError(err.message || "Failed to save capture"); + }, + ), + ); + }, [settings, isSubmitting]); const handleClose = useCallback(() => { windowRef.current.close().catch((err) => { @@ -73,18 +193,22 @@ export function QuickCaptureApp() { if (isLoading) { return ( -
+
); } return ( - +
+ +
); } diff --git a/src/components/QuickCapture/QuickCaptureForm.tsx b/src/components/QuickCapture/QuickCaptureForm.tsx index 84dd5d2..d58a8f3 100644 --- a/src/components/QuickCapture/QuickCaptureForm.tsx +++ b/src/components/QuickCapture/QuickCaptureForm.tsx @@ -1,17 +1,37 @@ import { Button } from "$components/Button"; -import { XIcon } from "$icons"; +import { ContextMenu, type ContextMenuDivider, type ContextMenuItem, useContextMenu } from "$components/ContextMenu"; +import { CHROME_SECTION, NO_MOTION_TRANSITION, QUICK_CAPTURE } from "$constants"; +import { ChevronDownIcon, PenIcon, XIcon } from "$icons"; import type { CaptureMode } from "$types"; +import { formatShortcut } from "$utils/shortcuts"; import { cn } from "$utils/tw"; +import { AnimatePresence, motion } from "motion/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +export type QuickCaptureSaveTarget = { id: string; locationId: number; relPath: string; label: string }; + type QuickCaptureFormProps = { defaultMode?: CaptureMode; onSubmit: (text: string, mode: CaptureMode, destination?: { locationId: number; relPath: string }) => void; onClose: () => void; isSubmitting: boolean; error: string | null; + reduceMotion?: boolean; + saveTargets?: QuickCaptureSaveTarget[]; }; +type FormTransition = typeof CHROME_SECTION.TRANSITION | typeof NO_MOTION_TRANSITION; + +type ModeButtonsProps = { + currentMode: CaptureMode; + setMode: (mode: CaptureMode) => void; + isSubmitting: boolean; + transition: FormTransition; +}; + +const MODE_OPTIONS = ["QuickNote", "WritingSession", "Append"] as const satisfies readonly CaptureMode[]; +const EMPTY_SAVE_TARGETS: QuickCaptureSaveTarget[] = []; + const getModeLabel = (m: CaptureMode): string => { switch (m) { case "QuickNote": @@ -25,98 +45,357 @@ const getModeLabel = (m: CaptureMode): string => { } }; -type ModeButtonsProps = { currentMode: CaptureMode; setMode: (mode: CaptureMode) => void; isSubmitting: boolean }; +const getModeDescription = (m: CaptureMode): string => { + switch (m) { + case "QuickNote": + return "Capture and save immediately into your inbox."; + case "WritingSession": + return "Draft freely and save when you are ready."; + case "Append": + return "Append your note to the last configured target."; + default: + return "Capture a thought quickly."; + } +}; -type ModeButtonProps = ModeButtonsProps & { mode: CaptureMode }; +const getShortcutHint = (mode: CaptureMode): string => { + if (mode === "WritingSession") { + return `${formatShortcut("Cmd+Enter")} to save, Esc to close`; + } + return "Enter to save, Shift+Enter for newline, Esc to close"; +}; + +const ModeDescription = ({ mode, transition }: { mode: CaptureMode; transition: FormTransition }) => ( + + + {getModeDescription(mode)} + + +); -const ModeButton = ({ currentMode, setMode, isSubmitting, mode }: ModeButtonProps) => { +const ModeButton = ( + { mode, currentMode, setMode, isSubmitting, transition }: { + mode: CaptureMode; + currentMode: CaptureMode; + setMode: (mode: CaptureMode) => void; + isSubmitting: boolean; + transition: FormTransition; + }, +) => { const handleClick = useCallback(() => { setMode(mode); }, [mode, setMode]); - const modeLabel = useMemo(() => getModeLabel(mode), [mode]); - const classes = useMemo( () => cn( - "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", + "relative rounded px-2.5 py-1.5 text-xs font-medium transition-[color,background-color,opacity,border-color] duration-150 sm:text-sm", + mode === currentMode ? "text-text-primary" : "text-text-secondary hover:text-text-primary", ), [mode, currentMode], ); - return ; + return ( + + ); }; -const ModeButtons = ({ currentMode, setMode, isSubmitting }: ModeButtonsProps) => ( -
- {(["QuickNote", "WritingSession", "Append"] as CaptureMode[]).map((m) => ( - +const ModeButtons = ({ currentMode, setMode, isSubmitting, transition }: ModeButtonsProps) => ( +
+ {MODE_OPTIONS.map((modeOption) => ( + ))}
); -const ErrorMessage = ({ error }: { error: string | null }) => { - return
{error && {error}}
; -}; +const CaptureTitle = ({ mode, transition }: { mode: CaptureMode; transition: FormTransition }) => ( +
+ + + +
+

Quick Capture

+ +
+
+); -const FooterActions = ( - { mode, isSubmitting, handleSubmit, text }: { - mode: CaptureMode; - isSubmitting: boolean; - handleSubmit: () => void; +const QuickCaptureFormHeader = ( + { onClose, currentMode, setMode, isSubmitting, transition }: ModeButtonsProps & { onClose: () => void }, +) => ( +
+
+ + +
+ +
+); + +const CaptureMetaRow = ({ mode, noteStats }: { mode: CaptureMode; noteStats: string }) => ( +
+ {getModeLabel(mode)} mode + {noteStats} +
+); + +const CaptureTextarea = ( + { textareaRef, text, isSubmitting, onChange, onKeyDown }: { + textareaRef: React.RefObject; text: string; + isSubmitting: boolean; + onChange: (e: React.ChangeEvent) => void; + onKeyDown: (e: React.KeyboardEvent) => void; }, ) => ( -
- - {mode === "WritingSession" - ? "Cmd+Enter to save, Esc to close" - : "Enter to save, Shift+Enter for newline, Esc to close"} - - - +
+