From 9d29959fbab9403339073ab71c7bfcd473a02817 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sun, 15 Feb 2026 10:00:29 +0100 Subject: [PATCH] Add frameless window with custom drag regions (Obsidian-style) Remove native macOS titlebar via decorations:false + titleBarStyle:overlay. Add -webkit-app-region:drag to panel headers (Sidebar, NoteList, Editor tab bar, Inspector) with no-drag on interactive elements. Sidebar header gets 78px left padding for macOS traffic light buttons. Co-Authored-By: Claude Opus 4.6 --- src-tauri/tauri.conf.json | 5 +- src/components/Editor.css | 12 + src/components/Editor.tsx | 3 +- src/components/Inspector.css | 213 ++++++++++- src/components/Inspector.tsx | 671 ++++++++++++++++++++++++++++++++--- src/components/NoteList.css | 7 + src/components/NoteList.tsx | 17 +- src/components/Sidebar.css | 10 +- src/components/Sidebar.tsx | 15 +- 9 files changed, 898 insertions(+), 55 deletions(-) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 863c007f..3a507c1b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -10,13 +10,16 @@ "beforeBuildCommand": "pnpm build" }, "app": { + "withGlobalTauri": true, "windows": [ { "title": "Laputa", "width": 1400, "height": 900, "resizable": true, - "fullscreen": false + "fullscreen": false, + "decorations": false, + "titleBarStyle": "overlay" } ], "security": { diff --git a/src/components/Editor.css b/src/components/Editor.css index c57b265c..a36744b0 100644 --- a/src/components/Editor.css +++ b/src/components/Editor.css @@ -29,6 +29,14 @@ color: #383858; } +/* Drag strip for frameless window (shown when no tabs are open) */ +.editor__drag-strip { + height: 48px; + flex-shrink: 0; + -webkit-app-region: drag; + app-region: drag; +} + /* Tab bar */ .editor__tab-bar { display: flex; @@ -36,6 +44,8 @@ border-bottom: 1px solid #2a2a4a; overflow-x: auto; flex-shrink: 0; + -webkit-app-region: drag; + app-region: drag; } .editor__tab-bar::-webkit-scrollbar { @@ -55,6 +65,8 @@ max-width: 180px; flex-shrink: 0; transition: all 0.1s; + -webkit-app-region: no-drag; + app-region: no-drag; } .editor__tab:hover { diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 70aa6370..a0769ebe 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -120,6 +120,7 @@ export function Editor({ tabs, activeTabPath, onSwitchTab, onCloseTab, onNavigat if (tabs.length === 0) { return (
+

Select a note to start editing

Cmd+P to search · Cmd+N to create @@ -130,7 +131,7 @@ export function Editor({ tabs, activeTabPath, onSwitchTab, onCloseTab, onNavigat return (
-
+
{tabs.map((tab) => (
gitHistory: GitCommit[] onNavigate: (target: string) => void + onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise + onDeleteProperty?: (path: string, key: string) => Promise + onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise +} + +export type FrontmatterValue = string | number | boolean | string[] | null + +interface ParsedFrontmatter { + [key: string]: FrontmatterValue } const STATUS_COLORS: Record = { @@ -19,14 +28,49 @@ const STATUS_COLORS: Record = { Paused: '#ff9800', Archived: '#9e9e9e', Dropped: '#f44336', + Open: '#4caf50', + Closed: '#9e9e9e', + 'Not started': '#888', + Draft: '#ff9800', + Mixed: '#ff9800', } +// Keys that are relationships (contain wikilinks) +const RELATIONSHIP_KEYS = new Set([ + 'Belongs to', + 'Related to', + 'Events', + 'Has Data', + 'Owner', + 'Advances', + 'Parent', + 'Children', + 'Has', + 'Notes', +]) + +// Keys to skip showing in Properties (shown elsewhere or internal) +const SKIP_KEYS = new Set([ + 'aliases', + 'notion_id', + 'workspace', +]) + function formatDate(timestamp: number | null): string { if (!timestamp) return '—' const d = new Date(timestamp * 1000) return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) } +function formatISODate(dateStr: string): string { + try { + const d = new Date(dateStr) + return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) + } catch { + return dateStr + } +} + function countWords(content: string | null): number { if (!content) return 0 // Strip YAML frontmatter @@ -35,17 +79,285 @@ function countWords(content: string | null): number { return words.length } -/** Extract display name from a wikilink like "[[responsibility/grow-newsletter]]" */ +/** Parse YAML frontmatter from content */ +function parseFrontmatter(content: string | null): ParsedFrontmatter { + if (!content) return {} + + const match = content.match(/^---\n([\s\S]*?)\n---/) + if (!match) return {} + + const yaml = match[1] + const result: ParsedFrontmatter = {} + + let currentKey: string | null = null + let currentList: string[] = [] + let inList = false + + const lines = yaml.split('\n') + + for (const line of lines) { + // Check for list item + const listMatch = line.match(/^ - (.*)$/) + if (listMatch && currentKey) { + inList = true + currentList.push(listMatch[1].replace(/^["']|["']$/g, '')) + continue + } + + // If we were in a list and hit a non-list line, save it + if (inList && currentKey) { + result[currentKey] = currentList.length === 1 ? currentList[0] : currentList + currentList = [] + inList = false + } + + // Check for key: value + const kvMatch = line.match(/^["']?([^"':]+)["']?\s*:\s*(.*)$/) + if (kvMatch) { + currentKey = kvMatch[1].trim() + const value = kvMatch[2].trim() + + if (value === '' || value === '|' || value === '>') { + // Empty value or multiline - wait for list items or next key + continue + } + + // Handle inline list like [item1, item2] + if (value.startsWith('[') && value.endsWith(']')) { + const items = value.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, '')) + result[currentKey] = items.length === 1 ? items[0] : items + continue + } + + // Handle quoted string + const unquoted = value.replace(/^["']|["']$/g, '') + + // Handle boolean + if (unquoted.toLowerCase() === 'true') { + result[currentKey] = true + continue + } + if (unquoted.toLowerCase() === 'false') { + result[currentKey] = false + continue + } + + result[currentKey] = unquoted + } + } + + // Don't forget last list if any + if (inList && currentKey) { + result[currentKey] = currentList.length === 1 ? currentList[0] : currentList + } + + return result +} + +/** Check if a string is a wikilink */ +function isWikilink(value: string): boolean { + return /^\[\[.*\]\]$/.test(value) +} + +/** Check if a value contains wikilinks */ +function containsWikilinks(value: FrontmatterValue): boolean { + if (typeof value === 'string') return isWikilink(value) + if (Array.isArray(value)) return value.some(v => typeof v === 'string' && isWikilink(v)) + return false +} + +/** Extract display name from a wikilink like "[[responsibility/grow-newsletter|Grow Newsletter]]" */ function wikilinkDisplay(ref: string): string { const inner = ref.replace(/^\[\[|\]\]$/g, '') + // Check for pipe alias: [[path|Display Name]] + const pipeIdx = inner.indexOf('|') + if (pipeIdx !== -1) { + return inner.slice(pipeIdx + 1) + } // Take last path segment and convert to title case const last = inner.split('/').pop() ?? inner return last.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) } -/** Extract the raw target for navigation from a wikilink ref */ +/** Extract the target path for navigation from a wikilink ref */ function wikilinkTarget(ref: string): string { - return ref.replace(/^\[\[|\]\]$/g, '').split('/').pop()?.replace(/-/g, ' ') ?? ref + const inner = ref.replace(/^\[\[|\]\]$/g, '') + // Check for pipe alias: [[path|Display Name]] — use path part + const pipeIdx = inner.indexOf('|') + const path = pipeIdx !== -1 ? inner.slice(0, pipeIdx) : inner + // Return the full path for matching (not just last segment) + return path +} + +// Editable value component for inline editing +function EditableValue({ + value, + onSave, + onCancel, + isEditing, + onStartEdit +}: { + value: string + onSave: (newValue: string) => void + onCancel: () => void + isEditing: boolean + onStartEdit: () => void +}) { + const [editValue, setEditValue] = useState(value) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + onSave(editValue) + } else if (e.key === 'Escape') { + setEditValue(value) + onCancel() + } + } + + if (isEditing) { + return ( + setEditValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={() => onSave(editValue)} + autoFocus + /> + ) + } + + return ( + + {value || '—'} + + ) +} + +// Editable list component +function EditableList({ + items, + onSave, + onDelete, + label, +}: { + items: string[] + onSave: (newItems: string[]) => void + onDelete?: () => void + label: string +}) { + const [editingIndex, setEditingIndex] = useState(null) + const [editValue, setEditValue] = useState('') + const [isAddingNew, setIsAddingNew] = useState(false) + const [newValue, setNewValue] = useState('') + + const handleStartEdit = (index: number) => { + setEditingIndex(index) + setEditValue(items[index]) + } + + const handleSaveEdit = () => { + if (editingIndex !== null) { + const newItems = [...items] + if (editValue.trim()) { + newItems[editingIndex] = editValue.trim() + } else { + // Remove empty items + newItems.splice(editingIndex, 1) + } + onSave(newItems) + setEditingIndex(null) + } + } + + const handleDeleteItem = (index: number) => { + const newItems = items.filter((_, i) => i !== index) + onSave(newItems) + } + + const handleAddNew = () => { + if (newValue.trim()) { + onSave([...items, newValue.trim()]) + setNewValue('') + setIsAddingNew(false) + } + } + + const handleKeyDown = (e: React.KeyboardEvent, action: 'edit' | 'add') => { + if (e.key === 'Enter') { + if (action === 'edit') handleSaveEdit() + else handleAddNew() + } else if (e.key === 'Escape') { + if (action === 'edit') { + setEditingIndex(null) + setEditValue('') + } else { + setIsAddingNew(false) + setNewValue('') + } + } + } + + return ( +
+ {items.map((item, idx) => ( +
+ {editingIndex === idx ? ( + setEditValue(e.target.value)} + onKeyDown={(e) => handleKeyDown(e, 'edit')} + onBlur={handleSaveEdit} + autoFocus + /> + ) : ( + <> + handleStartEdit(idx)} + title="Click to edit" + > + {item} + + + + )} +
+ ))} + {isAddingNew ? ( + setNewValue(e.target.value)} + onKeyDown={(e) => handleKeyDown(e, 'add')} + onBlur={handleAddNew} + placeholder={`New ${label.toLowerCase()}...`} + autoFocus + /> + ) : ( + + )} +
+ ) } function RelationshipGroup({ label, refs, onNavigate }: { label: string; refs: string[]; onNavigate: (target: string) => void }) { @@ -54,9 +366,9 @@ function RelationshipGroup({ label, refs, onNavigate }: { label: string; refs: s
{label}
- {refs.map((ref) => ( + {refs.map((ref, idx) => ( + ) + } + + // Default: editable string + return ( + setEditingKey(key)} + onSave={(v) => handleSaveValue(key, v)} + onCancel={() => setEditingKey(null)} + /> + ) + } return (

Properties

+ {/* Always show Type from entry */} {entry.isA && (
Type {entry.isA}
)} - {entry.status && ( -
- Status - - {entry.status} + + {/* Dynamic properties from frontmatter */} + {propertyEntries.map(([key, value]) => ( +
+ + {key} + {onDeleteProperty && ( + + )} + {renderEditableValue(key, value)}
- )} - {entry.owner && ( -
- Owner - {entry.owner} -
- )} - {entry.cadence && ( -
- Cadence - {entry.cadence} -
- )} + ))} + + {/* Always show Modified and Words (read-only) */}
Modified {formatDate(entry.modifiedAt)} @@ -135,9 +652,41 @@ function PropertiesPanel({ entry, content }: { entry: VaultEntry; content: strin {wordCount}
- + + {/* Add property UI */} + {showAddDialog ? ( +
+ setNewKey(e.target.value)} + onKeyDown={handleAddKeyDown} + autoFocus + /> + setNewValue(e.target.value)} + onKeyDown={handleAddKeyDown} + /> +
+ + +
+
+ ) : ( + + )}
) } @@ -168,6 +717,8 @@ function useBacklinks( } if (content.includes(`[[${stem}]]`)) return true if (content.includes(`[[${pathStem}]]`)) return true + // Also check with pipe aliases + if (content.includes(`[[${pathStem}|`)) return true return false }) }, [entry, entries, allContent]) @@ -237,12 +788,43 @@ function GitHistoryPanel({ commits }: { commits: GitCommit[] }) { ) } -export function Inspector({ collapsed, onToggle, entry, content, entries, allContent, gitHistory, onNavigate }: InspectorProps) { +export function Inspector({ + collapsed, + onToggle, + entry, + content, + entries, + allContent, + gitHistory, + onNavigate, + onUpdateFrontmatter, + onDeleteProperty, + onAddProperty, +}: InspectorProps) { const backlinks = useBacklinks(entry, entries, allContent) + const frontmatter = useMemo(() => parseFrontmatter(content), [content]) + + const handleUpdateProperty = useCallback((key: string, value: FrontmatterValue) => { + if (entry && onUpdateFrontmatter) { + onUpdateFrontmatter(entry.path, key, value) + } + }, [entry, onUpdateFrontmatter]) + + const handleDeleteProperty = useCallback((key: string) => { + if (entry && onDeleteProperty) { + onDeleteProperty(entry.path, key) + } + }, [entry, onDeleteProperty]) + + const handleAddProperty = useCallback((key: string, value: FrontmatterValue) => { + if (entry && onAddProperty) { + onAddProperty(entry.path, key, value) + } + }, [entry, onAddProperty]) return (