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(), ); } diff --git a/apps/desktop/src/App.css b/apps/desktop/src/App.css index ad3e16b..5b8d832 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; @@ -78,7 +105,7 @@ body { } .sidebar { - width: 250px; + width: 276px; background-color: var(--ln-sidebar-bg); border-right: 1px solid var(--ln-border); display: flex; @@ -200,6 +227,34 @@ button:disabled { .file-tree { padding: 10px 0; + min-height: 100%; +} + +.file-tree.root-drop-active { + background: color-mix(in srgb, var(--ln-accent) 8%, transparent); +} + +.tree-root-drop-zone { + margin: 0 10px 8px; + padding: 6px 10px; + border: 1px dashed var(--ln-border); + border-radius: 6px; + color: var(--ln-muted); + font-size: 0.8rem; + text-align: center; + transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease; +} + +.tree-root-drop-zone.active { + border-color: var(--ln-accent); + background: color-mix(in srgb, var(--ln-accent) 14%, transparent); + color: var(--ln-fg); +} + +.tree-root-list.active { + background: color-mix(in srgb, var(--ln-accent) 8%, transparent); + outline: 1px dashed var(--ln-accent); + outline-offset: -1px; } .tree-node { @@ -212,12 +267,33 @@ button:disabled { align-items: center; color: var(--ln-sidebar-fg); cursor: pointer; + width: 100%; + box-sizing: border-box; + -webkit-user-drag: element; } .node-label:hover { background-color: var(--ln-item-hover-bg); } +.node-label.drop-target { + background: color-mix(in srgb, var(--ln-accent) 16%, transparent); + outline: 1px solid var(--ln-accent); + outline-offset: -1px; +} + +.node-label.root-drop-hover { + background: color-mix(in srgb, var(--ln-accent) 12%, transparent); + outline: 1px dashed var(--ln-accent); + outline-offset: -1px; +} + +.node-children.drop-target-area { + background: color-mix(in srgb, var(--ln-accent) 8%, transparent); + outline: 1px dashed var(--ln-accent); + outline-offset: -1px; +} + .node-label.file { color: var(--ln-sidebar-fg); opacity: 0.9; @@ -505,6 +581,10 @@ button:disabled { overflow: hidden; } +.document-column { + border-left: 1px solid var(--ln-border); +} + .editor-pane, .preview-pane { flex: 1; display: flex; diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index d10d546..e0a114c 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"; @@ -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); @@ -60,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 @@ -74,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; @@ -96,8 +98,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) { @@ -183,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'; @@ -219,15 +223,122 @@ 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); + setIsCreatingFolder(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); + 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) { + setIsCreatingFolder(false); + 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"); + } finally { + setIsCreatingFolder(false); + setEditingPath(null); + } + }, [refreshFiles]); + + const remapOpenTabsAfterMove = useCallback((sourcePath: string, destinationPath: string) => { + 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); + }); + }, [openTabs, renameTab]); + + 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(); + remapOpenTabsAfterMove(sourcePath, destinationPath); + } catch (e) { + alert("Failed to move item: " + String(e)); + } + }, [refreshFiles, remapOpenTabsAfterMove]); + + const handleMoveToRoot = useCallback(async (sourcePath: string) => { + const sourceName = sourcePath.split('/').pop() ?? sourcePath; + if (!sourcePath.includes('/')) return; + + try { + await desktopVault.rename(sourcePath, sourceName); + await refreshFiles(); + remapOpenTabsAfterMove(sourcePath, sourceName); + } catch (e) { + alert("Failed to move item: " + String(e)); + } + }, [refreshFiles, remapOpenTabsAfterMove]); + const handleRenameCommit = useCallback(async (oldPath: string, newName: string) => { if (!newName || !newName.trim()) { setEditingPath(null); @@ -254,20 +365,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) { @@ -275,7 +374,7 @@ function AppContent() { } finally { setEditingPath(null); } - }, [refreshFiles, openTabs, closeTab, openTab]); + }, [refreshFiles, openTabs, renameTab]); const handleFileDelete = useCallback(async (path: string) => { try { @@ -368,6 +467,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) => { @@ -409,6 +517,9 @@ function AppContent() {