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
{modeLabel};
+ return (
+
+ {mode === currentMode && (
+
+ )}
+ {getModeLabel(mode)}
+
+ );
};
-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 }) => (
+
+);
-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"}
-
-
-
- {isSubmitting ? "Saving..." : "Save"}
-
+
+
);
-const QuickCaptureFormHeader = (
- { onClose, currentMode, setMode, isSubmitting }: ModeButtonsProps & { onClose: () => void },
+const ComposePane = (
+ { mode, noteStats, text, isSubmitting, textareaRef, onTextChange, onTextareaKeyDown, transition }: {
+ mode: CaptureMode;
+ noteStats: string;
+ text: string;
+ isSubmitting: boolean;
+ textareaRef: React.RefObject
;
+ onTextChange: (e: React.ChangeEvent) => void;
+ onTextareaKeyDown: (e: React.KeyboardEvent) => void;
+ transition: FormTransition;
+ },
) => (
-
-
+
+
+
+
+);
+
+function FooterStatus(
+ { error, mode, transition, selectedSaveTargetLabel }: {
+ error: string | null;
+ mode: CaptureMode;
+ transition: FormTransition;
+ selectedSaveTargetLabel: string | null;
+ },
+) {
+ const message = useMemo(
+ () => error ?? (selectedSaveTargetLabel ? `Save To: ${selectedSaveTargetLabel}` : getShortcutHint(mode)),
+ [error, selectedSaveTargetLabel, mode],
+ );
+ const statusClass = useMemo(() => error ? "text-support-error" : "text-text-placeholder", [error]);
+ const statusKey = useMemo(() => error ?? selectedSaveTargetLabel ?? mode, [error, selectedSaveTargetLabel, mode]);
+
+ return (
+
+ );
+}
+
+function FooterActions(
+ { mode, isSubmitting, canSubmit, onSubmit, onClose, saveTargets, selectedSaveTargetId, onSaveTargetSelect }: {
+ mode: CaptureMode;
+ isSubmitting: boolean;
+ canSubmit: boolean;
+ onSubmit: () => void;
+ onClose: () => void;
+ saveTargets: QuickCaptureSaveTarget[];
+ selectedSaveTargetId: string | null;
+ onSaveTargetSelect: (targetId: string | null) => void;
+ },
+) {
+ const { isOpen, position, openAt, close } = useContextMenu();
+ const isAppendMode = mode === "Append";
+ const canChooseSaveTarget = !isAppendMode && saveTargets.length > 0;
+ const menuItems = useMemo<(ContextMenuItem | ContextMenuDivider)[]>(() => {
+ if (isAppendMode) {
+ return [{ label: "Append mode uses append target", onClick: () => {}, disabled: true }];
+ }
+
+ if (saveTargets.length === 0) {
+ return [{ label: "No directories found", onClick: () => {}, disabled: true }];
+ }
+
+ return [
+ { label: "Save (Auto)", onClick: () => onSaveTargetSelect(null) },
+ { divider: true },
+ ...saveTargets.map((target) => ({
+ label: `Save to ${target.label}`,
+ onClick: () => onSaveTargetSelect(target.id),
+ })),
+ ];
+ }, [isAppendMode, onSaveTargetSelect, saveTargets]);
+
+ const handleOpenSaveTo = useCallback((event: React.MouseEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+
+ const rect = event.currentTarget.getBoundingClientRect();
+ openAt(rect.left, rect.bottom + 6);
+ }, [openAt]);
+
+ const saveLabel = selectedSaveTargetId ? "Save To" : "Save Capture";
+ const saveButtonLabel = isSubmitting ? "Saving..." : saveLabel;
+
+ return (
+ <>
+
+
+ Cancel
+
+
+
+ {saveButtonLabel}
+
+
+
+
+
+ >
+ );
+}
+
+function SaveToMenuButton(
+ { onOpen, disabled }: { onOpen: (event: React.MouseEvent) => void; disabled: boolean },
+) {
+ return (
-
+ variant="primaryBlue"
+ size="lg"
+ className="w-10 shrink-0 rounded-l-none px-0"
+ aria-label="Choose save destination"
+ title="Save To"
+ onClick={onOpen}
+ disabled={disabled}>
+
-
-);
+ );
+}
export function QuickCaptureForm(
- { defaultMode = "QuickNote", onSubmit, onClose, isSubmitting, error }: QuickCaptureFormProps,
+ {
+ defaultMode = "QuickNote",
+ onSubmit,
+ onClose,
+ isSubmitting,
+ error,
+ reduceMotion = false,
+ saveTargets = EMPTY_SAVE_TARGETS,
+ }: QuickCaptureFormProps,
) {
const [text, setText] = useState("");
const [mode, setMode] = useState(defaultMode);
+ const [selectedSaveTargetId, setSelectedSaveTargetId] = useState(null);
const textareaRef = useRef(null);
+ const transition = useMemo(() => reduceMotion ? NO_MOTION_TRANSITION : CHROME_SECTION.TRANSITION, [reduceMotion]);
useEffect(() => {
textareaRef.current?.focus();
}, []);
+ const canSubmit = useMemo(() => text.trim().length > 0, [text]);
+ const noteStats = useMemo(() => {
+ if (text.length === 0) {
+ return "0 lines · 0 chars";
+ }
+
+ const lineCount = text.split(/\r\n|\r|\n/).length;
+ return `${lineCount} line${lineCount > 1 ? "s" : ""} · ${text.length} chars`;
+ }, [text]);
+
+ const selectedSaveTarget = useMemo(() => saveTargets.find((target) => target.id === selectedSaveTargetId) ?? null, [
+ saveTargets,
+ selectedSaveTargetId,
+ ]);
+
+ useEffect(() => {
+ if (selectedSaveTargetId === null) {
+ return;
+ }
+
+ if (!saveTargets.some((target) => target.id === selectedSaveTargetId)) {
+ setSelectedSaveTargetId(null);
+ }
+ }, [saveTargets, selectedSaveTargetId]);
+
const handleSubmit = useCallback(() => {
- if (isSubmitting || !text.trim()) return;
+ if (isSubmitting || !canSubmit) {
+ return;
+ }
+
+ if (mode !== "Append" && selectedSaveTarget) {
+ onSubmit(text, mode, { locationId: selectedSaveTarget.locationId, relPath: selectedSaveTarget.relPath });
+ return;
+ }
+
onSubmit(text, mode);
- }, [text, mode, isSubmitting, onSubmit]);
+ }, [text, mode, isSubmitting, canSubmit, onSubmit, selectedSaveTarget]);
+
+ const handleSaveTargetSelect = useCallback((targetId: string | null) => {
+ setSelectedSaveTargetId(targetId);
+ }, []);
const handleTextChange = useCallback((e: React.ChangeEvent) => {
setText(e.target.value);
@@ -125,7 +404,9 @@ export function QuickCaptureForm(
const handleTextareaKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
- onClose();
+ if (!isSubmitting) {
+ onClose();
+ }
return;
}
@@ -140,28 +421,50 @@ export function QuickCaptureForm(
e.preventDefault();
handleSubmit();
}
- }, [handleSubmit, mode, onClose]);
+ }, [handleSubmit, isSubmitting, mode, onClose]);
return (
-
+
);
}
diff --git a/src/components/Sidebar/Sidebar.tsx b/src/components/Sidebar/Sidebar.tsx
index a783f5f..95ce2cc 100644
--- a/src/components/Sidebar/Sidebar.tsx
+++ b/src/components/Sidebar/Sidebar.tsx
@@ -3,6 +3,7 @@ import { useSidebarActions } from "$hooks/controllers/useSidebarActions";
import { CollapseIcon, FileAddIcon, FolderAddIcon, RefreshIcon } from "$icons";
import { useSidebarState } from "$state/selectors";
import type { DocMeta } from "$types";
+import { formatShortcut } from "$utils/shortcuts";
import { useCallback, useMemo, useState } from "react";
import { AddButton } from "./AddButton";
import {
@@ -38,7 +39,7 @@ const HideSidebarButton = ({ onToggleCollapse }: { onToggleCollapse: () => void
size="sm"
onClick={onToggleCollapse}
className="flex items-center gap-1.5"
- title="Hide sidebar (Ctrl+B)">
+ title={`Hide sidebar (${formatShortcut("Cmd+B")})`}>
Hide
diff --git a/src/components/Toolbar/Toolbar.tsx b/src/components/Toolbar/Toolbar.tsx
index fa8551b..d5c8d3c 100644
--- a/src/components/Toolbar/Toolbar.tsx
+++ b/src/components/Toolbar/Toolbar.tsx
@@ -14,6 +14,7 @@ import {
} from "$icons";
import { useLayoutSettingsUiState, useToolbarState } from "$state/selectors";
import { SaveStatus } from "$types";
+import { formatShortcut } from "$utils/shortcuts";
import { useCallback, useMemo } from "react";
import { SaveStatusIndicator } from "./SaveStatusIndicator";
import { ToolbarButton } from "./ToolbarButton";
@@ -80,7 +81,6 @@ export function Toolbar(
[],
);
- const iconOnly = useMemo(() => isNarrow, [isNarrow]);
const hideRefresh = useMemo(() => viewportWidth < 1080, [viewportWidth]);
const compactStatus = useMemo(() => viewportWidth < 960, [viewportWidth]);
const isEditorOnly = useMemo(() => !isSplitView && !isPreviewVisible, [isSplitView, isPreviewVisible]);
@@ -93,7 +93,7 @@ export function Toolbar(
label="Save"
onClick={onSave}
disabled={saveStatus === "Saved" || saveStatus === "Saving"}
- shortcut="Ctrl+S"
+ shortcut={formatShortcut("Cmd+S")}
iconOnly={isCompact} />
{onNewDocument && (
)}
{hasActiveDocument ? : null}
{onRefresh && !hideRefresh && (
-
+
)}
@@ -116,31 +116,31 @@ export function Toolbar(
label="Editor"
isActive={isEditorOnly}
onClick={setEditorOnlyMode}
- iconOnly={iconOnly} />
+ iconOnly={isNarrow} />
+ shortcut={formatShortcut("Cmd+\\")}
+ iconOnly={isNarrow} />
+ shortcut={formatShortcut("Cmd+P")}
+ iconOnly={isNarrow} />
+ shortcut={formatShortcut("Cmd+F")}
+ iconOnly={isNarrow} />
@@ -150,9 +150,9 @@ export function Toolbar(
label={isExportingPdf ? "Exporting" : "Export PDF"}
onClick={onExportPdf}
disabled={isExportingPdf || isPdfExportDisabled}
- iconOnly={iconOnly} />
+ iconOnly={isNarrow} />
)}
-
+
);
diff --git a/src/constants.ts b/src/constants.ts
index 61e30a5..9dd3fb4 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -85,3 +85,12 @@ export const DIALOG_MOTION_PRESETS: Record<
slideUp: { initial: { opacity: 0, y: 24 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: 18 } },
slideRight: { initial: { opacity: 0, x: 18 }, animate: { opacity: 1, x: 0 }, exit: { opacity: 0, x: 12 } },
};
+
+export const QUICK_CAPTURE = {
+ FORM: { INITIAL: { opacity: 0, y: 8 } as const, ANIMATE: { opacity: 1, y: 0 } as const },
+ TEXT: {
+ INITIAL: { opacity: 0, y: 4 } as const,
+ ANIMATE: { opacity: 1, y: 0 } as const,
+ EXIT: { opacity: 0, y: 2 } as const,
+ },
+};
diff --git a/src/utils/shortcuts.ts b/src/utils/shortcuts.ts
new file mode 100644
index 0000000..0344ff3
--- /dev/null
+++ b/src/utils/shortcuts.ts
@@ -0,0 +1,52 @@
+export function isMac(): boolean {
+ return typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
+}
+
+export function formatKey(key: string): string {
+ 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;
+ }
+}
+
+export function formatShortcut(shortcut: string): string {
+ return shortcut.split("+").map((part) => formatKey(part.trim())).join("+");
+}