From f668976b6548933a7ee223bd7a4f5f0a533f2605 Mon Sep 17 00:00:00 2001 From: Scott Morris Date: Wed, 11 Feb 2026 22:09:25 +0000 Subject: [PATCH 01/29] fix: align appearance controls with defaults and Linux-only accent option ## Summary Fix two appearance settings bugs in desktop: 1. align first-run Font size slider value with app default font size 2. show system accent colour option only on Linux and use Canadian spelling ## Changes - add shared `DEFAULT_FONT_SIZE` constant and use it for app fallback rendering - extend slider control with `defaultValue` support and apply it to `appearance.fontSize` - gate `Use system accent colour` row to Linux only in settings schema - pass Linux platform context into settings schema generation in settings modal - add schema tests for Linux gating, Canadian spelling, and font-size default alignment - update triage log entries for both bugs with repro, scope, and verification notes ## Verification - `pnpm --filter @liminal-notes/desktop test -- src/components/Settings/schemas.test.ts` (pass) - `pnpm --filter @liminal-notes/desktop lint` (pass) ## Notes The triage log remains a temporary branch artefact and will be cleaned from final PR history. Resolves #159 Resolves #160 --- apps/desktop/src/App.tsx | 5 +-- .../src/components/Settings/SettingsModal.tsx | 5 +-- .../src/components/Settings/controls.tsx | 2 +- .../src/components/Settings/schemas.test.ts | 33 +++++++++++++++++++ .../src/components/Settings/schemas.ts | 21 ++++++++---- apps/desktop/src/components/Settings/types.ts | 1 + apps/desktop/src/settings/defaults.ts | 1 + 7 files changed, 57 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/src/components/Settings/schemas.test.ts create mode 100644 apps/desktop/src/settings/defaults.ts diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index d10d546..8a26e05 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -24,6 +24,7 @@ import { RemindersProvider } from "./contexts/RemindersContext"; import { RemindersPanel } from "./features/reminders/RemindersPanel"; import { ReminderSheet } from "./features/reminders/components/ReminderSheet"; import { TitleBar } from "./components/TitleBar"; +import { DEFAULT_FONT_SIZE } from "./settings/defaults"; function matchShortcut(e: KeyboardEvent, commandId: string): boolean { const cmd = commandRegistry.getCommand(commandId); @@ -96,8 +97,8 @@ function AppContent() { document.body.style.fontSize = `${size}px`; } else { // Default font size - document.documentElement.style.setProperty('--ln-font-size', '16px'); - document.body.style.fontSize = '16px'; + document.documentElement.style.setProperty('--ln-font-size', `${DEFAULT_FONT_SIZE}px`); + document.body.style.fontSize = `${DEFAULT_FONT_SIZE}px`; } if (settings['editor.spellcheck.enabled'] !== undefined) { diff --git a/apps/desktop/src/components/Settings/SettingsModal.tsx b/apps/desktop/src/components/Settings/SettingsModal.tsx index 1cf38ae..cfead55 100644 --- a/apps/desktop/src/components/Settings/SettingsModal.tsx +++ b/apps/desktop/src/components/Settings/SettingsModal.tsx @@ -40,10 +40,11 @@ export const SettingsModal: React.FC = ({ onClose, onResetVa }; const appVersion = pkg.version; + const isLinux = navigator.userAgent.includes('Linux'); const sections = useMemo( - () => getSections(availableThemes, appVersion, enabledPlugins), - [availableThemes, appVersion, enabledPlugins] + () => getSections(availableThemes, appVersion, enabledPlugins, isLinux), + [availableThemes, appVersion, enabledPlugins, isLinux] ); const tagSection = { id: 'tags', title: 'Tag Management', settings: [], groups: [] }; diff --git a/apps/desktop/src/components/Settings/controls.tsx b/apps/desktop/src/components/Settings/controls.tsx index beccd13..cdaccb0 100644 --- a/apps/desktop/src/components/Settings/controls.tsx +++ b/apps/desktop/src/components/Settings/controls.tsx @@ -177,7 +177,7 @@ export const NumberInput: React.FC<{ def: SettingControlDef }> = ({ def }) => { export const Slider: React.FC<{ def: SettingControlDef }> = ({ def }) => { const [value, setValue] = useSettingValue(def.key); - const val = value ?? def.min ?? 0; + const val = value ?? def.defaultValue ?? def.min ?? 0; return (
diff --git a/apps/desktop/src/components/Settings/schemas.test.ts b/apps/desktop/src/components/Settings/schemas.test.ts new file mode 100644 index 0000000..f6fe104 --- /dev/null +++ b/apps/desktop/src/components/Settings/schemas.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { getSections } from './schemas'; +import { DEFAULT_FONT_SIZE } from '../../settings/defaults'; + +const getAppearanceRows = (isLinux: boolean) => { + const sections = getSections([], '0.1.0', new Set(), isLinux); + const appearance = sections.find((section) => section.id === 'appearance'); + expect(appearance).toBeDefined(); + return appearance!.groups[0].rows; +}; + +describe('settings appearance schema', () => { + it('hides system accent control on non-Linux', () => { + const rows = getAppearanceRows(false); + expect(rows.some((row) => row.id === 'system-accent')).toBe(false); + }); + + it('shows system accent control on Linux with Canadian spelling', () => { + const rows = getAppearanceRows(true); + const accentRow = rows.find((row) => row.id === 'system-accent'); + expect(accentRow).toBeDefined(); + expect(accentRow?.label).toBe('Use system accent colour'); + expect(accentRow?.description).toContain('accent colour'); + }); + + it('sets font size slider default to app default font size', () => { + const rows = getAppearanceRows(false); + const fontSizeRow = rows.find((row) => row.id === 'font-size'); + expect(fontSizeRow).toBeDefined(); + expect(fontSizeRow?.controls[0].kind).toBe('slider'); + expect(fontSizeRow?.controls[0].defaultValue).toBe(DEFAULT_FONT_SIZE); + }); +}); diff --git a/apps/desktop/src/components/Settings/schemas.ts b/apps/desktop/src/components/Settings/schemas.ts index 731eea2..2f253d4 100644 --- a/apps/desktop/src/components/Settings/schemas.ts +++ b/apps/desktop/src/components/Settings/schemas.ts @@ -1,11 +1,13 @@ import { SettingsSectionDef } from './types'; import { Theme } from '../../theme/types'; import { builtInPlugins } from '../../plugins/registry'; +import { DEFAULT_FONT_SIZE } from '../../settings/defaults'; export const getSections = ( availableThemes: Theme[], appVersion: string, - enabledPlugins: Set + enabledPlugins: Set, + isLinux: boolean ): SettingsSectionDef[] => { // Sort themes: System, then Light themes, then Dark themes const lightThemes = availableThemes @@ -145,12 +147,12 @@ export const getSections = ( options: themeOptions }] }, - { + ...(isLinux ? [{ id: 'system-accent', - label: 'Use system accent color', - description: 'Sync the accent color with your desktop environment (Linux only).', + label: 'Use system accent colour', + description: 'Sync the accent colour with your desktop environment.', controls: [{ kind: 'boolean', key: 'appearance.useSystemAccent' }] - }, + }] : []), { id: 'native-decorations', label: 'Use native window decorations', @@ -160,7 +162,14 @@ export const getSections = ( { id: 'font-size', label: 'Font size', - controls: [{ kind: 'slider', key: 'appearance.fontSize', min: 10, max: 30, step: 1 }] + controls: [{ + kind: 'slider', + key: 'appearance.fontSize', + min: 10, + max: 30, + step: 1, + defaultValue: DEFAULT_FONT_SIZE + }] }, { id: 'time-format', diff --git a/apps/desktop/src/components/Settings/types.ts b/apps/desktop/src/components/Settings/types.ts index 6ab2846..0b10b6a 100644 --- a/apps/desktop/src/components/Settings/types.ts +++ b/apps/desktop/src/components/Settings/types.ts @@ -29,6 +29,7 @@ export interface SettingControlDef { min?: number; max?: number; step?: number; + defaultValue?: number; intent?: "normal" | "danger"; disabled?: boolean; actionId?: string; // identifier for action handlers diff --git a/apps/desktop/src/settings/defaults.ts b/apps/desktop/src/settings/defaults.ts new file mode 100644 index 0000000..d433224 --- /dev/null +++ b/apps/desktop/src/settings/defaults.ts @@ -0,0 +1 @@ +export const DEFAULT_FONT_SIZE = 16; From 32e0629122b141555e8316cf4c153dcd967936ac Mon Sep 17 00:00:00 2001 From: Scott Morris Date: Wed, 11 Feb 2026 22:17:22 +0000 Subject: [PATCH 02/29] fix: restore editor setting defaults and active line toggle on desktop ## Summary Fix three desktop Editor settings regressions: 1. restore missing Highlight active line setting (default off) 2. make Word wrap default on 3. make Enable spellcheck default on in settings UI ## Changes - add `Highlight active line` row to Settings -> Editor behaviour controls - wire active-line highlighting in `CodeMirrorEditor` to `editor.highlightActiveLine` instead of always-on - default word-wrap runtime behaviour to enabled when setting is unset - add boolean `defaultValue` support in settings controls so first-run toggle states are accurate - set schema defaults: `editor.wordWrap=true`, `editor.spellcheck.enabled=true`, `editor.highlightActiveLine=false` - add schema tests for restored control and default values - append triage log entries for BUG-20260211-03/04/05 ## Verification - `pnpm --filter @liminal-notes/desktop test -- src/components/Settings/schemas.test.ts` (pass) - `pnpm --filter @liminal-notes/desktop lint` (pass) ## Notes The triage log remains a temporary branch artefact and will be cleaned from final PR history. Resolves #161 Resolves #162 Resolves #163 --- .../components/Editor/CodeMirrorEditor.tsx | 9 +++-- .../src/components/Editor/EditorPane.tsx | 3 +- .../src/components/Settings/controls.tsx | 6 ++- .../src/components/Settings/schemas.test.ts | 38 +++++++++++++++++++ .../src/components/Settings/schemas.ts | 22 ++++++++++- apps/desktop/src/components/Settings/types.ts | 2 +- 6 files changed, 71 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/components/Editor/CodeMirrorEditor.tsx b/apps/desktop/src/components/Editor/CodeMirrorEditor.tsx index a199942..1932fd6 100644 --- a/apps/desktop/src/components/Editor/CodeMirrorEditor.tsx +++ b/apps/desktop/src/components/Editor/CodeMirrorEditor.tsx @@ -35,13 +35,14 @@ interface CodeMirrorEditorProps { getEditorContext: (view: EditorView) => EditorContext; onLinkClick?: (target: string) => void; showLineNumbers?: boolean; + highlightActiveLineEnabled?: boolean; readableLineLength?: boolean; wordWrap?: boolean; extensions?: Extension[]; } export const CodeMirrorEditor = forwardRef( - ({ value, initialState, onChange, onSave, onBlur, noteId, path, getEditorContext, onLinkClick, showLineNumbers = true, readableLineLength = false, wordWrap = false, extensions: propExtensions = [] }, ref) => { + ({ value, initialState, onChange, onSave, onBlur, noteId, path, getEditorContext, onLinkClick, showLineNumbers = true, highlightActiveLineEnabled = false, readableLineLength = false, wordWrap = false, extensions: propExtensions = [] }, ref) => { const editorRef = useRef(null); const viewRef = useRef(null); const onSaveRef = useRef(onSave); @@ -51,6 +52,7 @@ export const CodeMirrorEditor = forwardRef( const { themeId } = useTheme(); const lineNumbersCompartment = useRef(new Compartment()).current; + const highlightActiveLineCompartment = useRef(new Compartment()).current; const wordWrapCompartment = useRef(new Compartment()).current; const propExtensionsCompartment = useRef(new Compartment()).current; @@ -149,7 +151,7 @@ export const CodeMirrorEditor = forwardRef( const extensions = [ lineNumbersCompartment.of(showLineNumbers ? lineNumbers() : []), wordWrapCompartment.of(wordWrap ? EditorView.lineWrapping : []), - highlightActiveLine(), + highlightActiveLineCompartment.of(highlightActiveLineEnabled ? highlightActiveLine() : []), history(), closeBrackets(), markdown({ extensions: [GFM] }), @@ -239,12 +241,13 @@ export const CodeMirrorEditor = forwardRef( return (n - frontmatterOffset).toString(); } }) : []), + highlightActiveLineCompartment.reconfigure(highlightActiveLineEnabled ? highlightActiveLine() : []), wordWrapCompartment.reconfigure(wordWrap ? EditorView.lineWrapping : []), propExtensionsCompartment.reconfigure(propExtensions) ] }); } - }, [showLineNumbers, wordWrap, frontmatterOffset, propExtensions]); + }, [showLineNumbers, highlightActiveLineEnabled, wordWrap, frontmatterOffset, propExtensions]); // Handle incoming value changes useEffect(() => { diff --git a/apps/desktop/src/components/Editor/EditorPane.tsx b/apps/desktop/src/components/Editor/EditorPane.tsx index a5624f0..3a3bdc6 100644 --- a/apps/desktop/src/components/Editor/EditorPane.tsx +++ b/apps/desktop/src/components/Editor/EditorPane.tsx @@ -874,8 +874,9 @@ export function EditorPane({ onRefreshFiles }: EditorPaneProps) { getEditorContext={getEditorContext} onLinkClick={handleNavigate} showLineNumbers={settings['editor.showLineNumbers'] !== false} + highlightActiveLineEnabled={settings['editor.highlightActiveLine'] === true} readableLineLength={settings['editor.readableLineLength'] === true} - wordWrap={settings['editor.wordWrap'] === true} + wordWrap={settings['editor.wordWrap'] !== false} extensions={ttsExtensions} /> )} diff --git a/apps/desktop/src/components/Settings/controls.tsx b/apps/desktop/src/components/Settings/controls.tsx index cdaccb0..c2918e7 100644 --- a/apps/desktop/src/components/Settings/controls.tsx +++ b/apps/desktop/src/components/Settings/controls.tsx @@ -14,12 +14,13 @@ function useSettingValue(key?: string) { export const ToggleSwitch: React.FC<{ def: SettingControlDef }> = ({ def }) => { const [value, setValue] = useSettingValue(def.key); + const current = value ?? (typeof def.defaultValue === 'boolean' ? def.defaultValue : false); return (
diff --git a/apps/desktop/src/components/Editor/previewContent.test.ts b/apps/desktop/src/components/Editor/previewContent.test.ts new file mode 100644 index 0000000..a610e8b --- /dev/null +++ b/apps/desktop/src/components/Editor/previewContent.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { buildPreviewContent } from './previewContent'; + +describe('buildPreviewContent', () => { + it('strips YAML front matter by default', () => { + const input = [ + '---', + 'title: Test', + 'tags:', + ' - one', + '---', + '', + '# Heading', + 'Body text' + ].join('\n'); + + const output = buildPreviewContent(input, false); + expect(output).toContain('# Heading'); + expect(output).toContain('Body text'); + expect(output).not.toContain('title: Test'); + expect(output).not.toContain('tags:'); + }); + + it('keeps YAML front matter when showFrontmatter is enabled', () => { + const input = ['---', 'title: Test', '---', '', 'Body'].join('\n'); + const output = buildPreviewContent(input, true); + expect(output).toContain('title: Test'); + expect(output).toContain('Body'); + }); + + it('converts wikilinks after front matter handling', () => { + const input = ['---', 'title: Test', '---', '', 'Link to [[Note A]]'].join('\n'); + const output = buildPreviewContent(input, false); + expect(output).toContain('[Note A](wikilink:Note A)'); + }); +}); diff --git a/apps/desktop/src/components/Editor/previewContent.ts b/apps/desktop/src/components/Editor/previewContent.ts new file mode 100644 index 0000000..7454208 --- /dev/null +++ b/apps/desktop/src/components/Editor/previewContent.ts @@ -0,0 +1,6 @@ +import { parseFrontmatter } from '@liminal-notes/core-shared/frontmatter'; + +export function buildPreviewContent(text: string, showFrontmatter: boolean): string { + const source = showFrontmatter ? text : parseFrontmatter(text).content; + return source.replace(/\[\[([^\]]+)\]\]/g, (_match, target) => `[${target}](wikilink:${target})`); +} diff --git a/apps/desktop/src/components/Settings/SettingsModal.tsx b/apps/desktop/src/components/Settings/SettingsModal.tsx index cfead55..aaf11b5 100644 --- a/apps/desktop/src/components/Settings/SettingsModal.tsx +++ b/apps/desktop/src/components/Settings/SettingsModal.tsx @@ -48,7 +48,10 @@ export const SettingsModal: React.FC = ({ onClose, onResetVa ); const tagSection = { id: 'tags', title: 'Tag Management', settings: [], groups: [] }; - const developerSections = [{ id: 'developer-window', title: 'Window sizing' }]; + const developerSections = [ + ...sections.filter(s => s.id === 'developer'), + { id: 'developer-window', title: 'Window sizing' } + ]; // Group sections for sidebar const optionsGroups = [ diff --git a/apps/desktop/src/components/Settings/schemas.test.ts b/apps/desktop/src/components/Settings/schemas.test.ts index 29618a3..44f1649 100644 --- a/apps/desktop/src/components/Settings/schemas.test.ts +++ b/apps/desktop/src/components/Settings/schemas.test.ts @@ -16,6 +16,13 @@ const getEditorSection = () => { return editor!; }; +const getDeveloperSection = () => { + const sections = getSections([], '0.1.0', new Set(), false); + const developer = sections.find((section) => section.id === 'developer'); + expect(developer).toBeDefined(); + return developer!; +}; + describe('settings appearance schema', () => { it('hides system accent control on non-Linux', () => { const rows = getAppearanceRows(false); @@ -78,3 +85,14 @@ describe('settings editor schema', () => { expect(spellcheckRow?.controls[0].defaultValue).toBe(true); }); }); + +describe('settings developer schema', () => { + it('includes show front matter toggle defaulting to disabled', () => { + const developer = getDeveloperSection(); + const visibilityGroup = developer.groups.find((group) => group.id === 'developer-visibility'); + const showFrontmatterRow = visibilityGroup?.rows.find((row) => row.id === 'show-frontmatter'); + expect(showFrontmatterRow).toBeDefined(); + expect(showFrontmatterRow?.controls[0].key).toBe('developer.showFrontmatter'); + expect(showFrontmatterRow?.controls[0].defaultValue).toBe(false); + }); +}); diff --git a/apps/desktop/src/components/Settings/schemas.ts b/apps/desktop/src/components/Settings/schemas.ts index 9b0e80b..aec0654 100644 --- a/apps/desktop/src/components/Settings/schemas.ts +++ b/apps/desktop/src/components/Settings/schemas.ts @@ -210,6 +210,27 @@ export const getSections = ( } ] }, + { + id: 'developer', + title: 'Developer', + groups: [ + { + id: 'developer-visibility', + rows: [ + { + id: 'show-frontmatter', + label: 'Show front matter', + description: 'Display YAML front matter in notes and preview.', + controls: [{ + kind: 'boolean', + key: 'developer.showFrontmatter', + defaultValue: false + }] + } + ] + } + ] + }, { id: 'core-plugins', title: 'Core plugins', From 05c3ba6a48d56db64668be6279633c925ac06e16 Mon Sep 17 00:00:00 2001 From: Scott Morris Date: Wed, 11 Feb 2026 22:43:25 +0000 Subject: [PATCH 06/29] fix: replace native context menus and add file-tree empty-space actions ## Summary Address desktop right-click behaviour issues by suppressing native webview context menus and adding file explorer empty-space actions. ## Changes - add global app-level `contextmenu` suppression to prevent native webview menu popups - add file-tree empty-space context menu using project `ContextMenu` - include `Add New Note` and `Add New Folder` entries in empty-space menu - wire folder creation to persist `/.keep` marker and refresh tree - add `createEmptySpaceMenuModel` helper with unit coverage - add triage entries `BUG-20260211-10` and `BUG-20260211-11` ## Verification - `pnpm --filter @liminal-notes/desktop test -- src/components/FileTree.test.ts` (pass) - `pnpm --filter @liminal-notes/desktop lint` (pass) ## Notes `docs/bug-triage-screenshots/` remains an untracked temporary artefact for later issue attachments. The triage log remains a temporary branch artefact and will be cleaned from final PR history. Resolves #168 Resolves #169 --- apps/desktop/src/App.tsx | 24 ++++++ apps/desktop/src/components/FileTree.test.ts | 33 +++++++- apps/desktop/src/components/FileTree.tsx | 82 +++++++++++++++++++- 3 files changed, 137 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 8a26e05..cd2992c 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -229,6 +229,20 @@ function AppContent() { setEditingPath(null); }, []); + const handleCreateFolder = useCallback(async (name: string) => { + const trimmed = name.trim().replace(/\/+$/g, ''); + if (!trimmed) return; + + try { + // Persist a hidden marker so empty folders are represented in the vault tree. + await desktopVault.writeNote(`${trimmed}/.keep`, ''); + await refreshFiles(); + } catch (e) { + console.error("Failed to create folder", e); + alert("Failed to create folder"); + } + }, [refreshFiles]); + const handleRenameCommit = useCallback(async (oldPath: string, newName: string) => { if (!newName || !newName.trim()) { setEditingPath(null); @@ -369,6 +383,15 @@ function AppContent() { }; }, [handleStartCreate, selectedFile, goBack, goForward]); + useEffect(() => { + const handleContextMenu = (e: MouseEvent) => { + e.preventDefault(); + }; + + window.addEventListener('contextmenu', handleContextMenu); + return () => window.removeEventListener('contextmenu', handleContextMenu); + }, []); + // Listen for view change events useEffect(() => { const handleViewChange = (e: Event) => { @@ -480,6 +503,7 @@ function AppContent() { onRename={handleRenameCommit} onCreate={handleCreateCommit} onStartCreate={() => setIsCreating(true)} // Allow context menu creation if implemented? + onCreateFolder={handleCreateFolder} onCancel={handleCreateCancel} onStartRename={(path) => setEditingPath(path)} onDelete={handleFileDelete} diff --git a/apps/desktop/src/components/FileTree.test.ts b/apps/desktop/src/components/FileTree.test.ts index 8c7c843..8280f7f 100644 --- a/apps/desktop/src/components/FileTree.test.ts +++ b/apps/desktop/src/components/FileTree.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { registerFileTreeRefreshListeners } from './FileTree'; +import { createEmptySpaceMenuModel, registerFileTreeRefreshListeners } from './FileTree'; describe('registerFileTreeRefreshListeners', () => { const callbacks = new Map void>(); @@ -42,3 +42,34 @@ describe('registerFileTreeRefreshListeners', () => { }); }); }); + +describe('createEmptySpaceMenuModel', () => { + it('creates add-note and add-folder entries with actions', () => { + const onAddNote = vi.fn(); + const onAddFolder = vi.fn(); + + const model = createEmptySpaceMenuModel(onAddNote, onAddFolder); + + expect(model.sections).toHaveLength(1); + const items = model.sections[0]?.items; + expect(items).toHaveLength(2); + + const addNote = items?.[0]; + const addFolder = items?.[1]; + + if (!addNote || !addFolder || !('action' in addNote) || !('action' in addFolder)) { + throw new Error('Menu model shape mismatch'); + } + + expect(addNote.id).toBe('fileTree.empty.addNote'); + expect(addNote.label).toBe('Add New Note'); + expect(addFolder.id).toBe('fileTree.empty.addFolder'); + expect(addFolder.label).toBe('Add New Folder'); + + addNote.action?.(); + addFolder.action?.(); + + expect(onAddNote).toHaveBeenCalledTimes(1); + expect(onAddFolder).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/desktop/src/components/FileTree.tsx b/apps/desktop/src/components/FileTree.tsx index 819751d..7cd92ee 100644 --- a/apps/desktop/src/components/FileTree.tsx +++ b/apps/desktop/src/components/FileTree.tsx @@ -41,6 +41,7 @@ interface FileTreeProps { isCreating?: boolean; onRename?: (oldPath: string, newName: string) => void; onCreate?: (name: string) => void; + onCreateFolder?: (name: string) => void; onStartCreate?: () => void; onCancel?: () => void; onDelete?: (path: string) => void; @@ -52,6 +53,32 @@ interface DisplayNode extends FileNode { isTemp?: boolean; } +export function createEmptySpaceMenuModel( + onAddNote: () => void, + onAddFolder: () => void +): MenuModel { + return { + sections: [ + { + items: [ + { + id: 'fileTree.empty.addNote', + label: 'Add New Note', + icon: 'plus-square', + action: onAddNote + }, + { + id: 'fileTree.empty.addFolder', + label: 'Add New Folder', + icon: 'folder-open', + action: onAddFolder + } + ] + } + ] + }; +} + export function FileTree({ files, onFileSelect, @@ -59,6 +86,7 @@ export function FileTree({ isCreating, onRename, onCreate, + onCreateFolder, onStartCreate, onCancel, onDelete, @@ -213,6 +241,45 @@ export function FileTree({ } }; + const handleEmptySpaceContextMenu = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const model = createEmptySpaceMenuModel( + () => { + if (onStartCreate) onStartCreate(); + }, + () => { + const name = window.prompt('Folder name'); + if (!name || !name.trim()) return; + if (onCreateFolder) onCreateFolder(name.trim()); + } + ); + + const context: FileContext = { + type: 'FileTree', + path: '', + isDir: true, + allFiles: new Set(files.map(f => f.path)), + operations: { + notify: () => {}, + refreshFiles: async () => { + if (onRefresh) await onRefresh(); + }, + startRename: () => {}, + deleteFileAndCleanup: async () => {}, + openTab: () => {}, + createNote: () => {} + } + }; + + setContextMenu({ + model, + position: { x: e.clientX, y: e.clientY }, + context + }); + }; + const handleKeyDown = async (e: React.KeyboardEvent) => { if (e.key === 'Delete' && selectedNode) { e.preventDefault(); @@ -223,7 +290,11 @@ export function FileTree({ if (files.length === 0 && !isCreating) { return ( -
+

This vault is empty.

{onStartCreate && (
); } @@ -250,6 +329,7 @@ export function FileTree({ className="file-tree" tabIndex={0} onKeyDown={handleKeyDown} + onContextMenu={handleEmptySpaceContextMenu} > {tree.map(node => ( Date: Wed, 11 Feb 2026 22:48:45 +0000 Subject: [PATCH 07/29] fix: expand file explorer empty-space context menu hit area ## Summary Make file explorer empty-space context menu available across the full visible pane. ## Changes - set `.file-tree` to `min-height: 100%` so its context-menu handler covers lower open space - add follow-up triage entry `BUG-20260211-12` documenting the hit-area regression and fix ## Verification - `pnpm --filter @liminal-notes/desktop lint` (pass) ## Notes This is a follow-up to BUG-20260211-11 after confirming menu visibility in real UI interaction. The triage log remains a temporary branch artefact and will be cleaned from final PR history. Resolves #170 --- apps/desktop/src/App.css | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/App.css b/apps/desktop/src/App.css index 2a0dcad..1a50d41 100644 --- a/apps/desktop/src/App.css +++ b/apps/desktop/src/App.css @@ -200,6 +200,7 @@ button:disabled { .file-tree { padding: 10px 0; + min-height: 100%; } .tree-node { From 64561c9d8aa396b9292869c9b269dff21e45ac07 Mon Sep 17 00:00:00 2001 From: Scott Morris Date: Wed, 11 Feb 2026 22:52:58 +0000 Subject: [PATCH 08/29] fix: keep context menus above scrollbar overlays ## Summary Prevent project context menus from rendering beneath overlay scrollbars. ## Changes - raise context menu and submenu z-index to near-max stack levels - add scrollbar-gutter-aware viewport clamping for main context menu positioning - add equivalent gutter-aware clamping for submenu positioning - log issue details in triage as `BUG-20260211-13` ## Verification - `pnpm --filter @liminal-notes/desktop lint` (pass) ## Notes User-reported screenshot capture is difficult because menu closes on capture interaction. The triage log remains a temporary branch artefact and will be cleaned from final PR history. Refs #171 --- .../components/Editor/ContextMenu/ContextMenu.css | 4 ++-- .../components/Editor/ContextMenu/ContextMenu.tsx | 13 +++++++++---- .../src/components/Editor/ContextMenu/Submenu.tsx | 7 +++++-- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/components/Editor/ContextMenu/ContextMenu.css b/apps/desktop/src/components/Editor/ContextMenu/ContextMenu.css index 587cfa4..d04127d 100644 --- a/apps/desktop/src/components/Editor/ContextMenu/ContextMenu.css +++ b/apps/desktop/src/components/Editor/ContextMenu/ContextMenu.css @@ -7,7 +7,7 @@ padding: 4px; min-width: 200px; max-width: 300px; - z-index: 10000; + z-index: 2147483000; color: var(--ln-menu-fg); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; user-select: none; @@ -23,7 +23,7 @@ padding: 4px; min-width: 200px; max-width: 300px; - z-index: 10001; /* Above main menu */ + z-index: 2147483001; /* Above main menu */ color: var(--ln-menu-fg); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; user-select: none; diff --git a/apps/desktop/src/components/Editor/ContextMenu/ContextMenu.tsx b/apps/desktop/src/components/Editor/ContextMenu/ContextMenu.tsx index cb29ad2..63a2fd9 100644 --- a/apps/desktop/src/components/Editor/ContextMenu/ContextMenu.tsx +++ b/apps/desktop/src/components/Editor/ContextMenu/ContextMenu.tsx @@ -18,6 +18,7 @@ export function ContextMenu({ onItemClick, }: ContextMenuProps) { const menuRef = useRef(null); + const SCROLLBAR_GUTTER_PX = 18; // Position menu and clamp to viewport useEffect(() => { @@ -33,15 +34,19 @@ export function ContextMenu({ let { x, y } = position; // Clamp horizontally - if (x + rect.width > viewport.width) { - x = viewport.width - rect.width - 8; + if (x + rect.width > viewport.width - SCROLLBAR_GUTTER_PX) { + x = viewport.width - rect.width - SCROLLBAR_GUTTER_PX; } // Clamp vertically - if (y + rect.height > viewport.height) { - y = viewport.height - rect.height - 8; + if (y + rect.height > viewport.height - SCROLLBAR_GUTTER_PX) { + y = viewport.height - rect.height - SCROLLBAR_GUTTER_PX; } + // Keep menu away from the outer edge to avoid overlay scrollbar occlusion. + x = Math.max(8, x); + y = Math.max(8, y); + menu.style.left = `${x}px`; menu.style.top = `${y}px`; }, [position]); diff --git a/apps/desktop/src/components/Editor/ContextMenu/Submenu.tsx b/apps/desktop/src/components/Editor/ContextMenu/Submenu.tsx index 81d4389..40d3ad7 100644 --- a/apps/desktop/src/components/Editor/ContextMenu/Submenu.tsx +++ b/apps/desktop/src/components/Editor/ContextMenu/Submenu.tsx @@ -22,6 +22,7 @@ export function Submenu({ }: SubmenuProps) { const submenuRef = useRef(null); const [position, setPosition] = useState({ x: 0, y: 0 }); + const SCROLLBAR_GUTTER_PX = 18; // Position submenu relative to parent useEffect(() => { @@ -39,17 +40,19 @@ export function Submenu({ let y = parentRect.top - 4; // Check if submenu would overflow right - if (x + rect.width > viewport.width) { + if (x + rect.width > viewport.width - SCROLLBAR_GUTTER_PX) { // Flip to left side of parent x = parentRect.left - rect.width + 4; } // Check if submenu would overflow bottom - if (y + rect.height > viewport.height) { + if (y + rect.height > viewport.height - SCROLLBAR_GUTTER_PX) { // Shift up to fit y = Math.max(8, viewport.height - rect.height - 8); } + // Keep menu away from edges to reduce overlay scrollbar collisions. + x = Math.max(8, x); // Ensure not off top y = Math.max(8, y); From e001ff8ef82888e302503b9a03843cb187a19d8e Mon Sep 17 00:00:00 2001 From: Scott Morris Date: Wed, 11 Feb 2026 23:00:11 +0000 Subject: [PATCH 09/29] fix: harden Linux X11 bootstrap against MIT-SHM startup crash ## Summary Mitigate Linux container/X11 startup failures that abort with `MIT-SHM` `BadAccess`. ## Changes - add `GDK_DISABLE_XSHM=1` in Linux container+X11 bootstrap path - ensure `GDK_DISABLE` includes `shm` for GTK-level shared memory disablement - keep existing `GDK_DISABLE_SHM=1` and WebKit software/container flags - extend bootstrap diagnostic log with `GDK_DISABLE_XSHM` and `GDK_DISABLE` - add triage entry `BUG-20260211-14` with reproducible error details ## Verification - `cargo check` in `apps/desktop/src-tauri` (pass) ## Notes This is a runtime environment hardening fix for dev-container X11 flows where `GDK_DISABLE_SHM` alone is insufficient. The triage log remains a temporary branch artefact and will be cleaned from final PR history. Resolves #172 --- apps/desktop/src-tauri/src/lib.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 6bce024..dd1998d 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -34,6 +34,21 @@ fn configure_linux_env() { } } + fn ensure_csv_env_flag(key: &str, flag: &str) { + let existing = env::var(key).unwrap_or_default(); + let mut values: Vec = existing + .split(',') + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(ToOwned::to_owned) + .collect(); + + if !values.iter().any(|v| v.eq_ignore_ascii_case(flag)) { + values.push(flag.to_string()); + env::set_var(key, values.join(",")); + } + } + // Cinnamon and similar Linux desktops often lack an accessibility bus, which causes // GTK/Wry to spam errors and sometimes abort the launch. Force-disable the AT-SPI // bridge when it is not explicitly configured. @@ -55,16 +70,20 @@ fn configure_linux_env() { set_env_if_unset("WEBKIT_DISABLE_COMPOSITING_MODE", "1"); set_env_if_unset("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); set_env_if_unset("GDK_DISABLE_SHM", "1"); + set_env_if_unset("GDK_DISABLE_XSHM", "1"); + ensure_csv_env_flag("GDK_DISABLE", "shm"); set_env_if_unset("WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS", "1"); } println!( - "[liminal-notes bootstrap] session_type={session_type:?} inferred_x11={inferred_x11} in_container={is_container} DISPLAY={display:?} WAYLAND_DISPLAY={wayland_display:?} NO_AT_BRIDGE={:?} WEBKIT_DISABLE_COMPOSITING_MODE={:?} WEBKIT_DISABLE_DMABUF_RENDERER={:?} WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS={:?} GDK_DISABLE_SHM={:?} LIBGL_ALWAYS_SOFTWARE={:?}", + "[liminal-notes bootstrap] session_type={session_type:?} inferred_x11={inferred_x11} in_container={is_container} DISPLAY={display:?} WAYLAND_DISPLAY={wayland_display:?} NO_AT_BRIDGE={:?} WEBKIT_DISABLE_COMPOSITING_MODE={:?} WEBKIT_DISABLE_DMABUF_RENDERER={:?} WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS={:?} GDK_DISABLE_SHM={:?} GDK_DISABLE_XSHM={:?} GDK_DISABLE={:?} LIBGL_ALWAYS_SOFTWARE={:?}", env::var("NO_AT_BRIDGE").ok(), env::var("WEBKIT_DISABLE_COMPOSITING_MODE").ok(), env::var("WEBKIT_DISABLE_DMABUF_RENDERER").ok(), env::var("WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS").ok(), env::var("GDK_DISABLE_SHM").ok(), + env::var("GDK_DISABLE_XSHM").ok(), + env::var("GDK_DISABLE").ok(), env::var("LIBGL_ALWAYS_SOFTWARE").ok(), ); } From e62bcc0966d271d60b4abc09a9107e1c8041740d Mon Sep 17 00:00:00 2001 From: Scott Morris Date: Wed, 11 Feb 2026 23:22:06 +0000 Subject: [PATCH 10/29] fix: commit new-note input on blur and add tab context menu ## Summary Address two desktop UX gaps: 1. file-tree inline new-note input did not create notes on enter/blur 2. tab headers lacked a right-click context menu ## Changes - implement `handleCreateCommit` in app shell to persist/open uniquely named notes from inline create flow - update FileTree inline input behaviour: - submit non-empty value on Enter - submit non-empty value on blur - avoid duplicate submit after Enter-triggered blur - add tab right-click context menu with actions: - Copy note path - Close tab - Close other tabs - Close tabs to the right - Close all tabs - add pure model builder `createTabContextMenuModel` and unit coverage - add triage entries `BUG-20260211-15` and `BUG-20260211-16` ## Verification - `pnpm --filter @liminal-notes/desktop test -- src/components/Editor/TabBar.test.ts src/components/FileTree.test.ts` (pass) - `pnpm --filter @liminal-notes/desktop lint` (pass) ## Notes The triage log remains a temporary branch artefact and will be cleaned from final PR history. Resolves #173 Resolves #174 --- apps/desktop/src/App.tsx | 43 ++++++- apps/desktop/src/components/Editor/Tab.tsx | 4 +- .../src/components/Editor/TabBar.test.ts | 50 ++++++++ apps/desktop/src/components/Editor/TabBar.tsx | 111 ++++++++++++++++++ apps/desktop/src/components/FileTree.tsx | 19 ++- 5 files changed, 220 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/components/Editor/TabBar.test.ts diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index cd2992c..a46cc21 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -220,9 +220,46 @@ function AppContent() { }, [openTab, resolvePath, refreshFiles]); - const handleCreateCommit = useCallback(async (_name: string) => { - // This is for the FileTree input if we still use it. - }, []); + const handleCreateCommit = useCallback(async (name: string) => { + const trimmed = name.trim(); + if (!trimmed) { + setIsCreating(false); + setEditingPath(null); + return; + } + + const hasMarkdownExtension = /\.md$/i.test(trimmed); + const baseTitle = hasMarkdownExtension ? trimmed.replace(/\.md$/i, '') : trimmed; + let path = hasMarkdownExtension ? trimmed : `${trimmed}.md`; + let counter = 1; + + while (resolvePath(path)) { + path = `${baseTitle} ${counter}.md`; + counter++; + } + + try { + await desktopVault.writeNote(path, ''); + openTab({ + id: path, + path, + title: path.split('/').pop()?.replace(/\.md$/i, '') || baseTitle, + mode: 'source', + isDirty: false, + isLoading: false, + isUnsaved: false, + isPreview: false, + editorState: '' + }); + await refreshFiles(); + } catch (e) { + console.error("Failed to create note from file tree input", e); + alert("Failed to create note"); + } finally { + setIsCreating(false); + setEditingPath(null); + } + }, [openTab, refreshFiles, resolvePath]); const handleCreateCancel = useCallback(() => { setIsCreating(false); diff --git a/apps/desktop/src/components/Editor/Tab.tsx b/apps/desktop/src/components/Editor/Tab.tsx index aca58fd..c1cc29d 100644 --- a/apps/desktop/src/components/Editor/Tab.tsx +++ b/apps/desktop/src/components/Editor/Tab.tsx @@ -7,14 +7,16 @@ interface TabProps { onSelect: () => void; onClose: (e: React.MouseEvent) => void; onDoubleClick: () => void; + onContextMenu?: (e: React.MouseEvent) => void; } -export function Tab({ tab, isActive, onSelect, onClose, onDoubleClick }: TabProps) { +export function Tab({ tab, isActive, onSelect, onClose, onDoubleClick, onContextMenu }: TabProps) { return (
{ if (e.button === 1) { // Middle click e.preventDefault(); diff --git a/apps/desktop/src/components/Editor/TabBar.test.ts b/apps/desktop/src/components/Editor/TabBar.test.ts new file mode 100644 index 0000000..dc7abf0 --- /dev/null +++ b/apps/desktop/src/components/Editor/TabBar.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createTabContextMenuModel } from './TabBar'; +import type { OpenTab } from '../../types/tabs'; + +describe('createTabContextMenuModel', () => { + const tabs: OpenTab[] = [ + { id: 'a.md', title: 'A', path: 'a.md', mode: 'source', isDirty: false, isLoading: false, isUnsaved: false, isPreview: false, editorState: '' }, + { id: 'b.md', title: 'B', path: 'b.md', mode: 'source', isDirty: false, isLoading: false, isUnsaved: false, isPreview: false, editorState: '' }, + { id: 'c.md', title: 'C', path: 'c.md', mode: 'source', isDirty: false, isLoading: false, isUnsaved: false, isPreview: false, editorState: '' } + ]; + + it('builds expected tab menu entries and action hooks', () => { + const actions = { + closeTab: vi.fn(), + closeTabs: vi.fn(), + closeOtherTabs: vi.fn(), + closeTabsToRight: vi.fn(), + copyPath: vi.fn() + }; + + const model = createTabContextMenuModel('b.md', 'b.md', tabs, actions); + expect(model.sections).toHaveLength(2); + const copyItem = model.sections[0]?.items[0]; + const closeItem = model.sections[1]?.items[0]; + + if (!copyItem || !closeItem || !('action' in copyItem) || !('action' in closeItem)) { + throw new Error('Unexpected menu model shape'); + } + + copyItem.action?.(); + closeItem.action?.(); + + expect(actions.copyPath).toHaveBeenCalledWith('b.md'); + expect(actions.closeTab).toHaveBeenCalledWith('b.md'); + }); + + it('disables close-right when selected tab is the last tab', () => { + const actions = { + closeTab: vi.fn(), + closeTabs: vi.fn(), + closeOtherTabs: vi.fn(), + closeTabsToRight: vi.fn(), + copyPath: vi.fn() + }; + + const model = createTabContextMenuModel('c.md', 'c.md', tabs, actions); + const closeRight = model.sections[1]?.items[2]; + expect(closeRight && 'disabled' in closeRight ? closeRight.disabled : undefined).toBe(true); + }); +}); diff --git a/apps/desktop/src/components/Editor/TabBar.tsx b/apps/desktop/src/components/Editor/TabBar.tsx index 648b2fc..8df450d 100644 --- a/apps/desktop/src/components/Editor/TabBar.tsx +++ b/apps/desktop/src/components/Editor/TabBar.tsx @@ -3,6 +3,8 @@ import { Tab } from './Tab'; import { OpenTab } from '../../types/tabs'; import { ChevronDownIcon } from '../Icons'; import { useTabs } from '../../contexts/TabsContext'; +import { ContextMenu } from './ContextMenu/ContextMenu'; +import type { MenuModel, MenuPosition } from './ContextMenu/types'; // Preload a tiny transparent image for optional drag ghost suppression const dragGhostImg = new Image(); @@ -18,12 +20,78 @@ interface TabBarProps { onKeepTab: (tabId: string) => void; } +export function createTabContextMenuModel( + tabId: string, + tabPath: string | null, + tabs: OpenTab[], + actions: { + closeTab: (id: string) => void; + closeTabs: (ids: string[]) => void; + closeOtherTabs: (id: string) => void; + closeTabsToRight: (id: string) => void; + copyPath: (path: string) => void; + } +): MenuModel { + const tabIndex = tabs.findIndex(t => t.id === tabId); + const tabsToRight = tabIndex >= 0 ? tabs.slice(tabIndex + 1).map(t => t.id) : []; + + return { + sections: [ + { + items: [ + { + id: 'tab.copyPath', + label: 'Copy note path', + icon: 'copy-clipboard', + disabled: !tabPath, + action: () => { + if (tabPath) actions.copyPath(tabPath); + } + } + ] + }, + { + items: [ + { + id: 'tab.close', + label: 'Close tab', + action: () => actions.closeTab(tabId) + }, + { + id: 'tab.closeOthers', + label: 'Close other tabs', + disabled: tabs.length <= 1, + action: () => actions.closeOtherTabs(tabId) + }, + { + id: 'tab.closeRight', + label: 'Close tabs to the right', + disabled: tabsToRight.length === 0, + action: () => actions.closeTabsToRight(tabId) + }, + { + id: 'tab.closeAll', + label: 'Close all tabs', + icon: 'trash', + disabled: tabs.length === 0, + action: () => actions.closeTabs(tabs.map(t => t.id)) + } + ] + } + ] + }; +} + export function TabBar({ tabs, activeTabId, onTabSwitch, onTabClose, onKeepTab }: TabBarProps) { const scrollContainerRef = useRef(null); const [isOverflowOpen, setIsOverflowOpen] = useState(false); const { reorderTabs } = useTabs(); const [draggedTabId, setDraggedTabId] = useState(null); const [hoverTabId, setHoverTabId] = useState(null); + const [contextMenu, setContextMenu] = useState<{ + model: MenuModel; + position: MenuPosition; + } | null>(null); const reorderFrame = useRef(null); const pendingTargetId = useRef(null); @@ -141,6 +209,38 @@ export function TabBar({ tabs, activeTabId, onTabSwitch, onTabClose, onKeepTab } setHoverTabId(null); }; + const closeTabs = (ids: string[]) => { + ids.forEach((id) => onTabClose(id)); + }; + + const handleTabContextMenu = (e: React.MouseEvent, tab: OpenTab) => { + e.preventDefault(); + e.stopPropagation(); + + const model = createTabContextMenuModel(tab.id, tab.path ?? null, tabs, { + closeTab: (id) => onTabClose(id), + closeTabs, + closeOtherTabs: (id) => closeTabs(tabs.filter(t => t.id !== id).map(t => t.id)), + closeTabsToRight: (id) => { + const idx = tabs.findIndex(t => t.id === id); + if (idx >= 0) closeTabs(tabs.slice(idx + 1).map(t => t.id)); + }, + copyPath: async (path) => { + try { + const { writeText } = await import('@tauri-apps/plugin-clipboard-manager'); + await writeText(path); + } catch (err) { + console.error('Failed to copy tab path', err); + } + } + }); + + setContextMenu({ + model, + position: { x: e.clientX, y: e.clientY } + }); + }; + return (
onTabSwitch(tab.id)} + onContextMenu={(e) => handleTabContextMenu(e, tab)} onClose={(e) => { e.stopPropagation(); // Stop propagation to wrapper if any onTabClose(tab.id); @@ -207,6 +308,16 @@ export function TabBar({ tabs, activeTabId, onTabSwitch, onTabClose, onKeepTab } )}
+ {contextMenu && ( + setContextMenu(null)} + onItemClick={(_itemId, action) => { + if (action) action(); + }} + /> + )}
); } diff --git a/apps/desktop/src/components/FileTree.tsx b/apps/desktop/src/components/FileTree.tsx index 7cd92ee..1505061 100644 --- a/apps/desktop/src/components/FileTree.tsx +++ b/apps/desktop/src/components/FileTree.tsx @@ -497,6 +497,7 @@ function TreeNode({ node, onFileSelect, editingPath, onRename, onCreate, onCance function NodeInput({ initialValue, isDir, originalExtension, onSubmit, onCancel }: { initialValue: string, isDir: boolean, originalExtension?: string, onSubmit: (val: string) => void, onCancel: () => void }) { const [val, setVal] = useState(initialValue); const inputRef = useRef(null); + const didSubmitRef = useRef(false); useEffect(() => { if (inputRef.current) { @@ -508,8 +509,13 @@ function NodeInput({ initialValue, isDir, originalExtension, onSubmit, onCancel const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { e.stopPropagation(); - if (val.trim()) onSubmit(val.trim()); - else onCancel(); + const next = val.trim(); + if (next) { + didSubmitRef.current = true; + onSubmit(next); + } else { + onCancel(); + } } else if (e.key === 'Escape') { e.stopPropagation(); onCancel(); @@ -531,7 +537,14 @@ function NodeInput({ initialValue, isDir, originalExtension, onSubmit, onCancel onKeyDown={handleKeyDown} onBlur={(e) => { e.stopPropagation(); - setTimeout(() => onCancel(), 100); + if (didSubmitRef.current) return; + const next = val.trim(); + if (next) { + didSubmitRef.current = true; + onSubmit(next); + } else { + onCancel(); + } }} style={{ fontFamily: 'inherit', From 9c4d6464dc53ef41baea05f30168263ef7dd27d4 Mon Sep 17 00:00:00 2001 From: Scott Morris Date: Wed, 11 Feb 2026 23:28:47 +0000 Subject: [PATCH 11/29] fix: add preview pane copy context menu including HTML option ## Summary Add explicit copy options to markdown preview right-click menu. ## Changes - add preview-pane context menu integration in `EditorPane` - add menu actions: - `Copy` (selected text) - `Copy as HTML` (rich clipboard when available, plain-text fallback) - scope selection extraction to preview DOM only to avoid cross-surface copy confusion - add pure model builder `createPreviewContextMenuModel` - add unit tests for preview context menu model enable/disable states - add triage entry `BUG-20260211-17` ## Verification - `pnpm --filter @liminal-notes/desktop test -- src/components/Editor/previewContextMenu.test.ts src/components/Editor/TabBar.test.ts src/components/FileTree.test.ts` (pass) - `pnpm --filter @liminal-notes/desktop lint` (pass) ## Notes Screenshot triage folder remains local/untracked for later issue attachment. The triage log remains a temporary branch artefact and will be cleaned from final PR history. Resolves #175 --- .../src/components/Editor/EditorPane.tsx | 106 +++++++++++++++++- .../Editor/previewContextMenu.test.ts | 39 +++++++ .../components/Editor/previewContextMenu.ts | 32 ++++++ 3 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/components/Editor/previewContextMenu.test.ts create mode 100644 apps/desktop/src/components/Editor/previewContextMenu.ts diff --git a/apps/desktop/src/components/Editor/EditorPane.tsx b/apps/desktop/src/components/Editor/EditorPane.tsx index 4674c42..f9e3f14 100644 --- a/apps/desktop/src/components/Editor/EditorPane.tsx +++ b/apps/desktop/src/components/Editor/EditorPane.tsx @@ -29,6 +29,9 @@ import { ttsHighlightField, setTtsHighlight } from '../../plugins/core.tts/highl import { listen } from '@tauri-apps/api/event'; import { FileConflictBanner } from '../FileConflictBanner'; import { buildPreviewContent } from './previewContent'; +import { ContextMenu } from './ContextMenu/ContextMenu'; +import type { MenuModel, MenuPosition } from './ContextMenu/types'; +import { createPreviewContextMenuModel } from './previewContextMenu'; interface EditorPaneProps { onRefreshFiles?: () => Promise; @@ -68,6 +71,10 @@ export function EditorPane({ onRefreshFiles }: EditorPaneProps) { const [isReminderModalOpen, setIsReminderModalOpen] = useState(false); const [isAddTagOpen, setIsAddTagOpen] = useState(false); const [conflictPath, setConflictPath] = useState(null); + const [previewContextMenu, setPreviewContextMenu] = useState<{ + model: MenuModel; + position: MenuPosition; + } | null>(null); // Track which tab the current 'content' belongs to to prevent data bleed const [loadedTabId, setLoadedTabId] = useState(null); @@ -77,6 +84,7 @@ export function EditorPane({ onRefreshFiles }: EditorPaneProps) { const editorRef = useRef(null); const contentRef = useRef(content); + const previewRef = useRef(null); useEffect(() => { contentRef.current = content; @@ -621,6 +629,87 @@ export function EditorPane({ onRefreshFiles }: EditorPaneProps) { const showFrontmatter = settings['developer.showFrontmatter'] === true; + const getPreviewSelection = () => { + const root = previewRef.current; + const selection = window.getSelection(); + if (!root || !selection || selection.rangeCount === 0 || selection.isCollapsed) { + return null; + } + + const range = selection.getRangeAt(0); + const container = range.commonAncestorContainer; + const element = container.nodeType === Node.ELEMENT_NODE ? container as Element : container.parentElement; + if (!element || !root.contains(element)) { + return null; + } + + const text = selection.toString().trim(); + if (!text) return null; + + const fragment = range.cloneContents(); + const wrapper = document.createElement('div'); + wrapper.appendChild(fragment); + + return { + text, + html: wrapper.innerHTML + }; + }; + + const handlePreviewCopyText = async () => { + const selected = getPreviewSelection(); + if (!selected) return; + + try { + const { writeText } = await import('@tauri-apps/plugin-clipboard-manager'); + await writeText(selected.text); + notify('Copied selection', 'success'); + } catch (err) { + notify('Failed to copy selection: ' + String(err), 'error'); + } + }; + + const handlePreviewCopyHtml = async () => { + const selected = getPreviewSelection(); + if (!selected) return; + + try { + const clipboard = navigator.clipboard as Clipboard & { + write?: (items: ClipboardItem[]) => Promise; + }; + + if (clipboard?.write && typeof ClipboardItem !== 'undefined') { + const item = new ClipboardItem({ + 'text/html': new Blob([selected.html], { type: 'text/html' }), + 'text/plain': new Blob([selected.text], { type: 'text/plain' }) + }); + await clipboard.write([item]); + } else { + const { writeText } = await import('@tauri-apps/plugin-clipboard-manager'); + await writeText(selected.text); + } + + notify('Copied selection as HTML', 'success'); + } catch (err) { + notify('Failed to copy as HTML: ' + String(err), 'error'); + } + }; + + const handlePreviewContextMenu = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const model = createPreviewContextMenuModel(!!getPreviewSelection(), { + copyText: () => { void handlePreviewCopyText(); }, + copyHtml: () => { void handlePreviewCopyHtml(); } + }); + + setPreviewContextMenu({ + model, + position: { x: e.clientX, y: e.clientY } + }); + }; + const MarkdownComponents = { a: ({ href, children, ...props }: any) => { if (href && href.startsWith('wikilink:')) { @@ -880,7 +969,11 @@ export function EditorPane({ onRefreshFiles }: EditorPaneProps) {
{showPreview && isReady && (
-
+
url} @@ -926,6 +1019,17 @@ export function EditorPane({ onRefreshFiles }: EditorPaneProps) {
)} + {previewContextMenu && ( + setPreviewContextMenu(null)} + onItemClick={(_itemId, action) => { + if (action) action(); + }} + /> + )} + {isReminderModalOpen && activeTab && ( setIsReminderModalOpen(false)} diff --git a/apps/desktop/src/components/Editor/previewContextMenu.test.ts b/apps/desktop/src/components/Editor/previewContextMenu.test.ts new file mode 100644 index 0000000..f0fe59a --- /dev/null +++ b/apps/desktop/src/components/Editor/previewContextMenu.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createPreviewContextMenuModel } from './previewContextMenu'; + +describe('createPreviewContextMenuModel', () => { + it('creates copy actions enabled when selection exists', () => { + const copyText = vi.fn(); + const copyHtml = vi.fn(); + + const model = createPreviewContextMenuModel(true, { copyText, copyHtml }); + const copyItem = model.sections[0]?.items[0]; + const copyHtmlItem = model.sections[0]?.items[1]; + + expect(copyItem && 'disabled' in copyItem ? copyItem.disabled : undefined).toBe(false); + expect(copyHtmlItem && 'disabled' in copyHtmlItem ? copyHtmlItem.disabled : undefined).toBe(false); + + if (!copyItem || !copyHtmlItem || !('action' in copyItem) || !('action' in copyHtmlItem)) { + throw new Error('Unexpected menu item shape'); + } + + copyItem.action?.(); + copyHtmlItem.action?.(); + + expect(copyText).toHaveBeenCalledTimes(1); + expect(copyHtml).toHaveBeenCalledTimes(1); + }); + + it('disables copy actions when selection is empty', () => { + const model = createPreviewContextMenuModel(false, { + copyText: vi.fn(), + copyHtml: vi.fn() + }); + + const copyItem = model.sections[0]?.items[0]; + const copyHtmlItem = model.sections[0]?.items[1]; + + expect(copyItem && 'disabled' in copyItem ? copyItem.disabled : undefined).toBe(true); + expect(copyHtmlItem && 'disabled' in copyHtmlItem ? copyHtmlItem.disabled : undefined).toBe(true); + }); +}); diff --git a/apps/desktop/src/components/Editor/previewContextMenu.ts b/apps/desktop/src/components/Editor/previewContextMenu.ts new file mode 100644 index 0000000..8de5795 --- /dev/null +++ b/apps/desktop/src/components/Editor/previewContextMenu.ts @@ -0,0 +1,32 @@ +import type { MenuModel } from './ContextMenu/types'; + +export function createPreviewContextMenuModel( + hasSelection: boolean, + actions: { + copyText: () => void; + copyHtml: () => void; + } +): MenuModel { + return { + sections: [ + { + items: [ + { + id: 'preview.copyText', + label: 'Copy', + icon: 'copy', + disabled: !hasSelection, + action: actions.copyText + }, + { + id: 'preview.copyHtml', + label: 'Copy as HTML', + icon: 'code', + disabled: !hasSelection, + action: actions.copyHtml + } + ] + } + ] + }; +} From 9e84b406a21dedc468c8b6e02ba26b5da03ac5bd Mon Sep 17 00:00:00 2001 From: Scott Morris Date: Wed, 11 Feb 2026 23:34:24 +0000 Subject: [PATCH 12/29] fix: disable accidental text selection in desktop UI chrome ## Summary Prevent accidental text selection in non-content desktop interface areas. ## Changes - add a scoped no-selection policy for UI chrome regions: - title bar - sidebar - tab bar - editor header - backlinks panel - include both `user-select: none` and `-webkit-user-select: none` for WebKitGTK consistency - explicitly preserve text selection in content/editing surfaces: - CodeMirror editor - markdown preview - inputs/textareas/contenteditable - make editable title input non-selectable by default, selectable while focused - add triage entry `BUG-20260211-18` ## Verification - `pnpm --filter @liminal-notes/desktop lint` (pass) ## Notes This aims to keep chrome interaction clean without sacrificing selection in editor/preview or form controls. The triage log remains a temporary branch artefact and will be cleaned from final PR history. Resolves #176 --- apps/desktop/src/App.css | 27 +++++++++++++++++++ .../src/components/Editor/EditableTitle.css | 4 +++ apps/desktop/src/components/TitleBar.css | 1 + 3 files changed, 32 insertions(+) diff --git a/apps/desktop/src/App.css b/apps/desktop/src/App.css index 1a50d41..1422526 100644 --- a/apps/desktop/src/App.css +++ b/apps/desktop/src/App.css @@ -45,6 +45,33 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Noto Color Emoji', 'Noto Emoji'; } +/* Prevent accidental text selection in UI chrome. */ +.title-bar, +.title-bar *, +.sidebar, +.sidebar *, +.tab-bar-container, +.tab-bar-container *, +.editor-header, +.editor-header *, +.backlinks-panel, +.backlinks-panel * { + user-select: none; + -webkit-user-select: none; +} + +/* Keep editing and reading surfaces selectable. */ +.cm-editor, +.cm-editor *, +.markdown-preview, +.markdown-preview *, +input, +textarea, +[contenteditable="true"] { + user-select: text; + -webkit-user-select: text; +} + #root { width: 100%; height: 100vh; diff --git a/apps/desktop/src/components/Editor/EditableTitle.css b/apps/desktop/src/components/Editor/EditableTitle.css index b8f9367..51ffcc7 100644 --- a/apps/desktop/src/components/Editor/EditableTitle.css +++ b/apps/desktop/src/components/Editor/EditableTitle.css @@ -17,6 +17,8 @@ width: 100%; box-sizing: border-box; cursor: text; + user-select: none; + -webkit-user-select: none; transition: background-color 0.1s, border-color 0.1s; } @@ -28,6 +30,8 @@ background: var(--ln-bg); border-color: var(--ln-border); box-shadow: 0 0 0 2px var(--ln-accent-transparent, rgba(66, 153, 225, 0.3)); + user-select: text; + -webkit-user-select: text; } .editable-title-error-banner { diff --git a/apps/desktop/src/components/TitleBar.css b/apps/desktop/src/components/TitleBar.css index 356cb95..d76fc55 100644 --- a/apps/desktop/src/components/TitleBar.css +++ b/apps/desktop/src/components/TitleBar.css @@ -7,6 +7,7 @@ align-items: center; justify-content: space-between; user-select: none; + -webkit-user-select: none; font-size: 0.85rem; color: var(--ln-fg); padding: 0; From 9fb3d2819fafefec6b7f852232628869074bd857 Mon Sep 17 00:00:00 2001 From: Scott Morris Date: Wed, 11 Feb 2026 23:39:50 +0000 Subject: [PATCH 13/29] fix: resolve backlink navigation for extension-less note paths ## Summary Normalise navigation targets before tab lookup/open so backlink navigation works when paths are provided without a `.md` suffix. ## Changes - add `resolveNavigablePath` helper for navigation-path normalisation - route `EditorPane.handleNavigate` through the helper - add unit coverage for resolve, passthrough, and fallback behaviour - record the bug and resolution details in `docs/BUG_TRIAGE_LOG.md` ## Verification - `pnpm --filter @liminal-notes/desktop test -- src/components/Editor/navigationPath.test.ts` - `pnpm --filter @liminal-notes/desktop lint` Resolves #177 --- .../src/components/Editor/EditorPane.tsx | 11 +++++--- .../components/Editor/navigationPath.test.ts | 25 +++++++++++++++++++ .../src/components/Editor/navigationPath.ts | 10 ++++++++ 3 files changed, 42 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/components/Editor/navigationPath.test.ts create mode 100644 apps/desktop/src/components/Editor/navigationPath.ts diff --git a/apps/desktop/src/components/Editor/EditorPane.tsx b/apps/desktop/src/components/Editor/EditorPane.tsx index f9e3f14..ead6d6b 100644 --- a/apps/desktop/src/components/Editor/EditorPane.tsx +++ b/apps/desktop/src/components/Editor/EditorPane.tsx @@ -32,6 +32,7 @@ import { buildPreviewContent } from './previewContent'; import { ContextMenu } from './ContextMenu/ContextMenu'; import type { MenuModel, MenuPosition } from './ContextMenu/types'; import { createPreviewContextMenuModel } from './previewContextMenu'; +import { resolveNavigablePath } from './navigationPath'; interface EditorPaneProps { onRefreshFiles?: () => Promise; @@ -763,22 +764,24 @@ export function EditorPane({ onRefreshFiles }: EditorPaneProps) { }; const handleNavigate = (path: string) => { + const navigablePath = resolveNavigablePath(path, resolvePath); + // Save current state before navigating if (activeTabId && editorRef.current) { const state = editorRef.current.getEditorState(); updateTabState(activeTabId, state); } - const existing = openTabs.find(t => t.path === path); + const existing = openTabs.find(t => t.path === navigablePath); if (existing) { switchTab(existing.id); } else { - const title = path.split('/').pop()?.replace('.md', '') || path; + const title = navigablePath.split('/').pop()?.replace('.md', '') || navigablePath; dispatch({ type: 'OPEN_TAB', tab: { - id: path, - path: path, + id: navigablePath, + path: navigablePath, title: title, mode: 'source', isDirty: false, diff --git a/apps/desktop/src/components/Editor/navigationPath.test.ts b/apps/desktop/src/components/Editor/navigationPath.test.ts new file mode 100644 index 0000000..1c4888e --- /dev/null +++ b/apps/desktop/src/components/Editor/navigationPath.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from 'vitest'; +import { resolveNavigablePath } from './navigationPath'; + +describe('resolveNavigablePath', () => { + it('resolves extension-less targets to canonical markdown paths', () => { + const resolvePath = vi.fn().mockReturnValue('folder/note.md'); + + expect(resolveNavigablePath('folder/note', resolvePath)).toBe('folder/note.md'); + expect(resolvePath).toHaveBeenCalledWith('folder/note'); + }); + + it('keeps markdown paths unchanged', () => { + const resolvePath = vi.fn(); + + expect(resolveNavigablePath('folder/note.md', resolvePath)).toBe('folder/note.md'); + expect(resolvePath).not.toHaveBeenCalled(); + }); + + it('falls back to trimmed path when resolver has no match', () => { + const resolvePath = vi.fn().mockReturnValue(undefined); + + expect(resolveNavigablePath(' note-without-extension ', resolvePath)).toBe('note-without-extension'); + expect(resolvePath).toHaveBeenCalledWith('note-without-extension'); + }); +}); diff --git a/apps/desktop/src/components/Editor/navigationPath.ts b/apps/desktop/src/components/Editor/navigationPath.ts new file mode 100644 index 0000000..d1004e4 --- /dev/null +++ b/apps/desktop/src/components/Editor/navigationPath.ts @@ -0,0 +1,10 @@ +export function resolveNavigablePath( + path: string, + resolvePath: (targetRaw: string) => string | undefined +): string { + const trimmed = path.trim(); + if (!trimmed) return path; + if (trimmed.endsWith('.md')) return trimmed; + + return resolvePath(trimmed) ?? trimmed; +} From 3fd08233d6c22973fc2b50f1eb13e97ff2c746f6 Mon Sep 17 00:00:00 2001 From: Scott Morris Date: Wed, 11 Feb 2026 23:49:20 +0000 Subject: [PATCH 14/29] fix: preserve tab order when renaming open notes ## Summary Prevent tab reordering during note rename by renaming tabs in place instead of closing and reopening them. ## Changes - add `RENAME_TAB` action and `renameTab` helper in tabs context - update file-explorer rename flow to call `renameTab` - update editor-title rename flow to call `renameTab` - add reducer tests for in-place rename order preservation and conflict merge behaviour - record bug details in `docs/BUG_TRIAGE_LOG.md` ## Verification - `pnpm --filter @liminal-notes/desktop test -- src/contexts/TabsContext.test.ts` - `pnpm --filter @liminal-notes/desktop lint` Resolves #178 --- apps/desktop/src/App.tsx | 20 ++------ .../src/components/Editor/EditorPane.tsx | 13 +---- apps/desktop/src/contexts/TabsContext.test.ts | 51 +++++++++++++++++++ apps/desktop/src/contexts/TabsContext.tsx | 46 +++++++++++++++++ 4 files changed, 103 insertions(+), 27 deletions(-) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index a46cc21..70609f0 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -61,7 +61,7 @@ function AppContent() { } = useVault(); // Use Tabs Context - const { openTab, switchTab, openTabs, closeTab, activeTabId, dispatch } = useTabs(); + const { openTab, switchTab, openTabs, closeTab, activeTabId, dispatch, renameTab } = useTabs(); // Use Navigation Context const { goBack, goForward } = useNavigation(); // Use Settings Context @@ -306,20 +306,8 @@ function AppContent() { await desktopVault.rename(oldPath, newPath); await refreshFiles(); - const oldTab = openTabs.find(t => t.id === oldPath); // Assuming ID=path - if (oldTab) { - closeTab(oldPath); - openTab({ - id: newPath, - path: newPath, - title: newFilename.replace('.md', ''), - mode: 'source', - isDirty: oldTab.isDirty, - isLoading: false, - isUnsaved: false, - isPreview: oldTab.isPreview, // Preserve preview state? Or force permanent? Usually permanent on rename. - editorState: oldTab.editorState - }); + if (openTabs.some(t => t.id === oldPath)) { + renameTab(oldPath, newPath, newPath, newFilename.replace('.md', '')); } } catch (e) { @@ -327,7 +315,7 @@ function AppContent() { } finally { setEditingPath(null); } - }, [refreshFiles, openTabs, closeTab, openTab]); + }, [refreshFiles, openTabs, renameTab]); const handleFileDelete = useCallback(async (path: string) => { try { diff --git a/apps/desktop/src/components/Editor/EditorPane.tsx b/apps/desktop/src/components/Editor/EditorPane.tsx index ead6d6b..b7c9754 100644 --- a/apps/desktop/src/components/Editor/EditorPane.tsx +++ b/apps/desktop/src/components/Editor/EditorPane.tsx @@ -49,6 +49,7 @@ export function EditorPane({ onRefreshFiles }: EditorPaneProps) { updateTabState, updateTabTitle, updateTabPath, + renameTab, updateTabAiState, closeTab: closeTabContext, openTab @@ -601,17 +602,7 @@ export function EditorPane({ onRefreshFiles }: EditorPaneProps) { await onRefreshFiles(); } - // Re-open tab with new ID - const newTab = { - ...activeTab, - id: newPath, - path: newPath, - title: newName, - }; - - // Close old and open new to update ID - closeTabContext(activeTab.id); - openTab(newTab); + renameTab(activeTab.id, newPath, newPath, newName); notify('Renamed successfully', 'success'); } catch (e) { diff --git a/apps/desktop/src/contexts/TabsContext.test.ts b/apps/desktop/src/contexts/TabsContext.test.ts index 9258435..4ac429d 100644 --- a/apps/desktop/src/contexts/TabsContext.test.ts +++ b/apps/desktop/src/contexts/TabsContext.test.ts @@ -99,4 +99,55 @@ describe('tabsReducer', () => { expect(newState.openTabs).toHaveLength(2); expect(newState.activeTabId).toBe('note-2'); }); + + it('should handle RENAME_TAB without changing tab order', () => { + const tab2: OpenTab = { ...mockTab, id: 'note-2', path: 'note-2.md', title: 'Note 2' }; + const tab3: OpenTab = { ...mockTab, id: 'note-3', path: 'note-3.md', title: 'Note 3' }; + const state: TabsState = { + openTabs: [mockTab, tab2, tab3], + activeTabId: tab2.id, + }; + + const newState = tabsReducer(state, { + type: 'RENAME_TAB', + tabId: 'note-2', + newId: 'renamed-note', + path: 'renamed-note.md', + title: 'Renamed Note', + }); + + expect(newState.openTabs.map(t => t.id)).toEqual(['note-1', 'renamed-note', 'note-3']); + expect(newState.openTabs[1].path).toBe('renamed-note.md'); + expect(newState.openTabs[1].title).toBe('Renamed Note'); + expect(newState.activeTabId).toBe('renamed-note'); + }); + + it('should merge when RENAME_TAB target id is already open', () => { + const tab2: OpenTab = { + ...mockTab, + id: 'note-2', + path: 'note-2.md', + title: 'Note 2', + isDirty: true, + editorState: '{"doc":"changed"}' + }; + const tab3: OpenTab = { ...mockTab, id: 'note-3', path: 'note-3.md', title: 'Note 3' }; + const state: TabsState = { + openTabs: [mockTab, tab2, tab3], + activeTabId: tab2.id, + }; + + const newState = tabsReducer(state, { + type: 'RENAME_TAB', + tabId: 'note-2', + newId: 'note-3', + path: 'note-3.md', + title: 'Note 3', + }); + + expect(newState.openTabs.map(t => t.id)).toEqual(['note-1', 'note-3']); + expect(newState.openTabs[1].isDirty).toBe(true); + expect(newState.openTabs[1].editorState).toBe('{"doc":"changed"}'); + expect(newState.activeTabId).toBe('note-3'); + }); }); diff --git a/apps/desktop/src/contexts/TabsContext.tsx b/apps/desktop/src/contexts/TabsContext.tsx index 2bb3104..c689c3a 100644 --- a/apps/desktop/src/contexts/TabsContext.tsx +++ b/apps/desktop/src/contexts/TabsContext.tsx @@ -10,6 +10,7 @@ type TabsAction = | { type: 'UPDATE_TAB_STATE'; tabId: string; editorState: string } | { type: 'UPDATE_TAB_TITLE'; tabId: string; title: string } | { type: 'UPDATE_TAB_PATH'; tabId: string; path: string; isUnsaved: boolean } + | { type: 'RENAME_TAB'; tabId: string; newId: string; path: string; title: string } | { type: 'UPDATE_TAB_AI_STATE'; tabId: string; aiState: AiState } | { type: 'LOAD_TABS'; tabs: OpenTab[]; activeTabId: string | null } | { type: 'REORDER_TABS'; fromIndex: number; toIndex: number }; @@ -106,6 +107,48 @@ export function tabsReducer(state: TabsState, action: TabsAction): TabsState { ) }; } + case 'RENAME_TAB': { + const sourceTab = state.openTabs.find(t => t.id === action.tabId); + if (!sourceTab) { + return state; + } + + // If target ID already exists, merge the renamed tab state onto the existing slot. + const hasConflict = action.newId !== action.tabId && state.openTabs.some(t => t.id === action.newId); + if (hasConflict) { + const nextTabs = state.openTabs + .filter(t => t.id !== action.tabId) + .map(t => t.id === action.newId + ? { + ...t, + path: action.path, + title: action.title, + isDirty: sourceTab.isDirty, + isLoading: sourceTab.isLoading, + isUnsaved: sourceTab.isUnsaved, + isPreview: sourceTab.isPreview, + editorState: sourceTab.editorState, + aiState: sourceTab.aiState + } + : t + ); + + const nextActive = state.activeTabId === action.tabId ? action.newId : state.activeTabId; + return { + ...state, + openTabs: nextTabs, + activeTabId: nextActive + }; + } + + return { + ...state, + openTabs: state.openTabs.map(t => + t.id === action.tabId ? { ...t, id: action.newId, path: action.path, title: action.title } : t + ), + activeTabId: state.activeTabId === action.tabId ? action.newId : state.activeTabId, + }; + } case 'UPDATE_TAB_AI_STATE': { return { ...state, @@ -149,6 +192,7 @@ interface TabsContextValue extends TabsState { updateTabState: (tabId: string, editorState: string) => void; updateTabTitle: (tabId: string, title: string) => void; updateTabPath: (tabId: string, path: string, isUnsaved: boolean) => void; + renameTab: (tabId: string, newId: string, path: string, title: string) => void; updateTabAiState: (tabId: string, aiState: AiState) => void; reorderTabs: (fromIndex: number, toIndex: number) => void; } @@ -222,6 +266,7 @@ export function TabsProvider({ children }: { children: React.ReactNode }) { const updateTabState = (tabId: string, editorState: string) => dispatch({ type: 'UPDATE_TAB_STATE', tabId, editorState }); const updateTabTitle = (tabId: string, title: string) => dispatch({ type: 'UPDATE_TAB_TITLE', tabId, title }); const updateTabPath = (tabId: string, path: string, isUnsaved: boolean) => dispatch({ type: 'UPDATE_TAB_PATH', tabId, path, isUnsaved }); + const renameTab = (tabId: string, newId: string, path: string, title: string) => dispatch({ type: 'RENAME_TAB', tabId, newId, path, title }); const updateTabAiState = (tabId: string, aiState: AiState) => dispatch({ type: 'UPDATE_TAB_AI_STATE', tabId, aiState }); const reorderTabs = (fromIndex: number, toIndex: number) => dispatch({ type: 'REORDER_TABS', fromIndex, toIndex }); @@ -236,6 +281,7 @@ export function TabsProvider({ children }: { children: React.ReactNode }) { updateTabState, updateTabTitle, updateTabPath, + renameTab, updateTabAiState, reorderTabs }; From fd0ab2b68d59ce7cd92fec3f0147554ee7653285 Mon Sep 17 00:00:00 2001 From: Scott Morris Date: Thu, 12 Feb 2026 00:13:28 +0000 Subject: [PATCH 15/29] fix: improve file explorer folder creation and drag-drop moves ## Summary Align folder workflows in file explorer by replacing prompt-based creation with inline input, adding a toolbar folder action, and enabling drag-and-drop moves into folders. ## Changes - add inline folder-creation state in file tree and remove `window.prompt` flow - add `New Folder` toolbar button to the left of `New Note` - implement folder-target drag/drop handlers in file tree with move guards - wire move operations in app shell through vault rename and refresh - update open tab paths when moved file/folder items are currently open - add helper tests for move-path construction and drop eligibility guards - add triage entries for the three related bugs in `docs/BUG_TRIAGE_LOG.md` ## Verification - `pnpm --filter @liminal-notes/desktop test -- src/components/FileTree.test.ts` - `pnpm --filter @liminal-notes/desktop lint` Resolves #179 Resolves #180 Resolves #181 --- apps/desktop/src/App.tsx | 58 ++++++++- apps/desktop/src/components/FileTree.test.ts | 29 ++++- apps/desktop/src/components/FileTree.tsx | 129 +++++++++++++++++-- 3 files changed, 204 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 70609f0..1c1fc12 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -11,7 +11,7 @@ import { StatusBar } from "./components/StatusBar"; import { HelpModal } from "./components/HelpModal"; import { useVault } from "./hooks/useVault"; import { desktopVault } from "./adapters/DesktopVaultAdapter"; -import { SearchIcon, DocumentTextIcon, ShareIcon, PencilSquareIcon, CogIcon, BellIcon } from "./components/Icons"; +import { SearchIcon, DocumentTextIcon, ShareIcon, PencilSquareIcon, CogIcon, BellIcon, FolderIcon } from "./components/Icons"; import { useTabs } from "./contexts/TabsContext"; import { useNavigation } from "./contexts/NavigationContext"; import { EditorPane } from "./components/Editor/EditorPane"; @@ -75,6 +75,7 @@ function AppContent() { const [editingPath, setEditingPath] = useState(null); const [isCreating, setIsCreating] = useState(false); + const [isCreatingFolder, setIsCreatingFolder] = useState(false); const [sidebarTab, setSidebarTab] = useState<'files' | 'tags'>('files'); const useNativeDecorations = (settings['appearance.useNativeDecorations'] as boolean) ?? false; @@ -184,6 +185,8 @@ function AppContent() { }; const handleStartCreate = useCallback(async () => { + setIsCreating(false); + setIsCreatingFolder(false); // New behavior: Immediately create "Untitled" file let title = 'Untitled'; let path = 'Untitled.md'; @@ -224,6 +227,7 @@ function AppContent() { const trimmed = name.trim(); if (!trimmed) { setIsCreating(false); + setIsCreatingFolder(false); setEditingPath(null); return; } @@ -257,18 +261,29 @@ function AppContent() { alert("Failed to create note"); } finally { setIsCreating(false); + setIsCreatingFolder(false); setEditingPath(null); } }, [openTab, refreshFiles, resolvePath]); const handleCreateCancel = useCallback(() => { setIsCreating(false); + setIsCreatingFolder(false); + setEditingPath(null); + }, []); + + const handleStartCreateFolder = useCallback(() => { + setIsCreating(false); + setIsCreatingFolder(true); setEditingPath(null); }, []); const handleCreateFolder = useCallback(async (name: string) => { const trimmed = name.trim().replace(/\/+$/g, ''); - if (!trimmed) return; + if (!trimmed) { + setIsCreatingFolder(false); + return; + } try { // Persist a hidden marker so empty folders are represented in the vault tree. @@ -277,9 +292,37 @@ function AppContent() { } catch (e) { console.error("Failed to create folder", e); alert("Failed to create folder"); + } finally { + setIsCreatingFolder(false); + setEditingPath(null); } }, [refreshFiles]); + const handleMoveIntoFolder = useCallback(async (sourcePath: string, targetFolderPath: string) => { + const sourceName = sourcePath.split('/').pop() ?? sourcePath; + const destinationPath = `${targetFolderPath}/${sourceName}`; + + if (destinationPath === sourcePath) return; + if (targetFolderPath.startsWith(`${sourcePath}/`)) return; + + try { + await desktopVault.rename(sourcePath, destinationPath); + await refreshFiles(); + + const movedTabs = openTabs + .filter(tab => tab.id === sourcePath || tab.id.startsWith(`${sourcePath}/`)) + .sort((a, b) => a.path.length - b.path.length); + + movedTabs.forEach((tab) => { + const suffix = tab.id.slice(sourcePath.length); + const nextPath = `${destinationPath}${suffix}`; + renameTab(tab.id, nextPath, nextPath, tab.title); + }); + } catch (e) { + alert("Failed to move item: " + String(e)); + } + }, [openTabs, refreshFiles, renameTab]); + const handleRenameCommit = useCallback(async (oldPath: string, newName: string) => { if (!newName || !newName.trim()) { setEditingPath(null); @@ -458,6 +501,9 @@ function AppContent() {