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 <noreply@anthropic.com>
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -120,6 +120,7 @@ export function Editor({ tabs, activeTabPath, onSwitchTab, onCloseTab, onNavigat
|
||||
if (tabs.length === 0) {
|
||||
return (
|
||||
<div className="editor">
|
||||
<div className="editor__drag-strip" data-tauri-drag-region />
|
||||
<div className="editor__placeholder">
|
||||
<p>Select a note to start editing</p>
|
||||
<span className="editor__placeholder-hint">Cmd+P to search · Cmd+N to create</span>
|
||||
@@ -130,7 +131,7 @@ export function Editor({ tabs, activeTabPath, onSwitchTab, onCloseTab, onNavigat
|
||||
|
||||
return (
|
||||
<div className="editor">
|
||||
<div className="editor__tab-bar">
|
||||
<div className="editor__tab-bar" data-tauri-drag-region>
|
||||
{tabs.map((tab) => (
|
||||
<div
|
||||
key={tab.entry.path}
|
||||
|
||||
@@ -19,6 +19,13 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
-webkit-app-region: drag;
|
||||
app-region: drag;
|
||||
}
|
||||
|
||||
.inspector__header button {
|
||||
-webkit-app-region: no-drag;
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
.inspector__header h3 {
|
||||
@@ -89,6 +96,61 @@
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.inspector__prop-value--editable {
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.inspector__prop-value--editable:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.inspector__edit-input {
|
||||
background: #2a2a4a;
|
||||
border: 1px solid #4a4a6a;
|
||||
color: #fff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.inspector__edit-input:focus {
|
||||
outline: none;
|
||||
border-color: #7eb8da;
|
||||
}
|
||||
|
||||
.inspector__prop-label {
|
||||
color: #888;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.inspector__prop-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.inspector__prop:hover .inspector__prop-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.inspector__prop-delete:hover {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.inspector__status-pill {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
@@ -98,6 +160,96 @@
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.inspector__status-pill--editable {
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.inspector__status-pill--editable:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.inspector__bool-toggle {
|
||||
background: none;
|
||||
border: 1px solid #444;
|
||||
color: #ccc;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.inspector__bool-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: #666;
|
||||
}
|
||||
|
||||
/* List editor */
|
||||
.inspector__list-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inspector__list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.inspector__list-item-text {
|
||||
flex: 1;
|
||||
color: #ccc;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.15s;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.inspector__list-item-text:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.inspector__list-item-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.inspector__list-item:hover .inspector__list-item-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.inspector__list-item-delete:hover {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.inspector__list-add {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
padding: 4px 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.inspector__list-add:hover {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.inspector__add-prop {
|
||||
margin-top: 12px;
|
||||
background: none;
|
||||
@@ -105,14 +257,67 @@
|
||||
color: #666;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: not-allowed;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.inspector__add-prop:hover {
|
||||
border-color: #555;
|
||||
color: #888;
|
||||
.inspector__add-prop:hover:not(:disabled) {
|
||||
border-color: #7eb8da;
|
||||
color: #7eb8da;
|
||||
}
|
||||
|
||||
.inspector__add-prop:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Add property dialog */
|
||||
.inspector__add-dialog {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: #2a2a4a;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #4a4a6a;
|
||||
}
|
||||
|
||||
.inspector__add-dialog-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.inspector__add-dialog-buttons button {
|
||||
background: #3a3a5a;
|
||||
border: none;
|
||||
color: #ccc;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.inspector__add-dialog-buttons button:hover:not(:disabled) {
|
||||
background: #4a4a6a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.inspector__add-dialog-buttons button:first-child {
|
||||
background: #4a6a8a;
|
||||
}
|
||||
|
||||
.inspector__add-dialog-buttons button:first-child:hover:not(:disabled) {
|
||||
background: #5a7a9a;
|
||||
}
|
||||
|
||||
.inspector__add-dialog-buttons button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Relationships */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useState, useCallback } from 'react'
|
||||
import type { VaultEntry, GitCommit } from '../types'
|
||||
import './Inspector.css'
|
||||
|
||||
@@ -11,6 +11,15 @@ interface InspectorProps {
|
||||
allContent: Record<string, string>
|
||||
gitHistory: GitCommit[]
|
||||
onNavigate: (target: string) => void
|
||||
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onDeleteProperty?: (path: string, key: string) => Promise<void>
|
||||
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
}
|
||||
|
||||
export type FrontmatterValue = string | number | boolean | string[] | null
|
||||
|
||||
interface ParsedFrontmatter {
|
||||
[key: string]: FrontmatterValue
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
@@ -19,14 +28,49 @@ const STATUS_COLORS: Record<string, string> = {
|
||||
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 (
|
||||
<input
|
||||
className="inspector__edit-input"
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => onSave(editValue)}
|
||||
autoFocus
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className="inspector__prop-value inspector__prop-value--editable"
|
||||
onClick={onStartEdit}
|
||||
title="Click to edit"
|
||||
>
|
||||
{value || '—'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Editable list component
|
||||
function EditableList({
|
||||
items,
|
||||
onSave,
|
||||
onDelete,
|
||||
label,
|
||||
}: {
|
||||
items: string[]
|
||||
onSave: (newItems: string[]) => void
|
||||
onDelete?: () => void
|
||||
label: string
|
||||
}) {
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(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 (
|
||||
<div className="inspector__list-editor">
|
||||
{items.map((item, idx) => (
|
||||
<div key={idx} className="inspector__list-item">
|
||||
{editingIndex === idx ? (
|
||||
<input
|
||||
className="inspector__edit-input"
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e, 'edit')}
|
||||
onBlur={handleSaveEdit}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className="inspector__list-item-text"
|
||||
onClick={() => handleStartEdit(idx)}
|
||||
title="Click to edit"
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
<button
|
||||
className="inspector__list-item-delete"
|
||||
onClick={() => handleDeleteItem(idx)}
|
||||
title="Remove item"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{isAddingNew ? (
|
||||
<input
|
||||
className="inspector__edit-input"
|
||||
type="text"
|
||||
value={newValue}
|
||||
onChange={(e) => setNewValue(e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e, 'add')}
|
||||
onBlur={handleAddNew}
|
||||
placeholder={`New ${label.toLowerCase()}...`}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
className="inspector__list-add"
|
||||
onClick={() => setIsAddingNew(true)}
|
||||
>
|
||||
+ Add item
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
<div className="inspector__rel-group">
|
||||
<span className="inspector__rel-label">{label}</span>
|
||||
<div className="inspector__rel-links">
|
||||
{refs.map((ref) => (
|
||||
{refs.map((ref, idx) => (
|
||||
<button
|
||||
key={ref}
|
||||
key={`${ref}-${idx}`}
|
||||
className="inspector__rel-link"
|
||||
onClick={() => onNavigate(wikilinkTarget(ref))}
|
||||
>
|
||||
@@ -68,10 +380,38 @@ function RelationshipGroup({ label, refs, onNavigate }: { label: string; refs: s
|
||||
)
|
||||
}
|
||||
|
||||
function RelationshipsPanel({ entry, onNavigate }: { entry: VaultEntry; onNavigate: (target: string) => void }) {
|
||||
const hasAny = entry.belongsTo.length > 0 || entry.relatedTo.length > 0
|
||||
function DynamicRelationshipsPanel({
|
||||
frontmatter,
|
||||
onNavigate
|
||||
}: {
|
||||
frontmatter: ParsedFrontmatter
|
||||
onNavigate: (target: string) => void
|
||||
}) {
|
||||
// Find all keys that contain wikilinks
|
||||
const relationshipEntries = useMemo(() => {
|
||||
return Object.entries(frontmatter)
|
||||
.filter(([key, value]) => {
|
||||
// Check if this key typically contains relationships or has wikilinks
|
||||
if (RELATIONSHIP_KEYS.has(key)) return true
|
||||
return containsWikilinks(value)
|
||||
})
|
||||
.map(([key, value]) => {
|
||||
const refs: string[] = []
|
||||
if (typeof value === 'string' && isWikilink(value)) {
|
||||
refs.push(value)
|
||||
} else if (Array.isArray(value)) {
|
||||
value.forEach(v => {
|
||||
if (typeof v === 'string' && isWikilink(v)) {
|
||||
refs.push(v)
|
||||
}
|
||||
})
|
||||
}
|
||||
return { key, refs }
|
||||
})
|
||||
.filter(({ refs }) => refs.length > 0)
|
||||
}, [frontmatter])
|
||||
|
||||
if (!hasAny) {
|
||||
if (relationshipEntries.length === 0) {
|
||||
return (
|
||||
<div className="inspector__section">
|
||||
<h4>Relationships</h4>
|
||||
@@ -83,49 +423,226 @@ function RelationshipsPanel({ entry, onNavigate }: { entry: VaultEntry; onNaviga
|
||||
return (
|
||||
<div className="inspector__section">
|
||||
<h4>Relationships</h4>
|
||||
<RelationshipGroup label="Belongs to" refs={entry.belongsTo} onNavigate={onNavigate} />
|
||||
<RelationshipGroup label="Related to" refs={entry.relatedTo} onNavigate={onNavigate} />
|
||||
{relationshipEntries.map(({ key, refs }) => (
|
||||
<RelationshipGroup key={key} label={key} refs={refs} onNavigate={onNavigate} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PropertiesPanel({ entry, content }: { entry: VaultEntry; content: string | null }) {
|
||||
const statusColor = entry.status ? STATUS_COLORS[entry.status] ?? '#888' : undefined
|
||||
function DynamicPropertiesPanel({
|
||||
entry,
|
||||
content,
|
||||
frontmatter,
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
onAddProperty,
|
||||
}: {
|
||||
entry: VaultEntry
|
||||
content: string | null
|
||||
frontmatter: ParsedFrontmatter
|
||||
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
|
||||
onDeleteProperty?: (key: string) => void
|
||||
onAddProperty?: (key: string, value: FrontmatterValue) => void
|
||||
}) {
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null)
|
||||
const [showAddDialog, setShowAddDialog] = useState(false)
|
||||
const [newKey, setNewKey] = useState('')
|
||||
const [newValue, setNewValue] = useState('')
|
||||
|
||||
const wordCount = countWords(content)
|
||||
|
||||
// Filter out relationship keys and skipped keys
|
||||
const propertyEntries = useMemo(() => {
|
||||
return Object.entries(frontmatter)
|
||||
.filter(([key, value]) => {
|
||||
if (SKIP_KEYS.has(key)) return false
|
||||
if (RELATIONSHIP_KEYS.has(key)) return false
|
||||
if (containsWikilinks(value)) return false
|
||||
return true
|
||||
})
|
||||
}, [frontmatter])
|
||||
|
||||
const handleSaveValue = useCallback((key: string, newValue: string) => {
|
||||
setEditingKey(null)
|
||||
if (onUpdateProperty) {
|
||||
// Try to preserve type
|
||||
if (newValue.toLowerCase() === 'true') {
|
||||
onUpdateProperty(key, true)
|
||||
} else if (newValue.toLowerCase() === 'false') {
|
||||
onUpdateProperty(key, false)
|
||||
} else if (!isNaN(Number(newValue)) && newValue.trim() !== '') {
|
||||
onUpdateProperty(key, Number(newValue))
|
||||
} else {
|
||||
onUpdateProperty(key, newValue)
|
||||
}
|
||||
}
|
||||
}, [onUpdateProperty])
|
||||
|
||||
const handleSaveList = useCallback((key: string, newItems: string[]) => {
|
||||
if (onUpdateProperty) {
|
||||
if (newItems.length === 0) {
|
||||
onDeleteProperty?.(key)
|
||||
} else if (newItems.length === 1) {
|
||||
onUpdateProperty(key, newItems[0])
|
||||
} else {
|
||||
onUpdateProperty(key, newItems)
|
||||
}
|
||||
}
|
||||
}, [onUpdateProperty, onDeleteProperty])
|
||||
|
||||
const handleAddProperty = useCallback(() => {
|
||||
if (newKey.trim() && onAddProperty) {
|
||||
// Check if it looks like a list
|
||||
if (newValue.includes(',')) {
|
||||
const items = newValue.split(',').map(s => s.trim()).filter(s => s)
|
||||
onAddProperty(newKey.trim(), items.length === 1 ? items[0] : items)
|
||||
} else {
|
||||
onAddProperty(newKey.trim(), newValue.trim() || '')
|
||||
}
|
||||
setNewKey('')
|
||||
setNewValue('')
|
||||
setShowAddDialog(false)
|
||||
}
|
||||
}, [newKey, newValue, onAddProperty])
|
||||
|
||||
const handleAddKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleAddProperty()
|
||||
} else if (e.key === 'Escape') {
|
||||
setShowAddDialog(false)
|
||||
setNewKey('')
|
||||
setNewValue('')
|
||||
}
|
||||
}
|
||||
|
||||
const renderEditableValue = (key: string, value: FrontmatterValue) => {
|
||||
if (value === null || value === undefined) {
|
||||
return (
|
||||
<EditableValue
|
||||
value=""
|
||||
isEditing={editingKey === key}
|
||||
onStartEdit={() => setEditingKey(key)}
|
||||
onSave={(v) => handleSaveValue(key, v)}
|
||||
onCancel={() => setEditingKey(null)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Status gets special rendering but is still editable
|
||||
if (key === 'Status' || key.includes('Status')) {
|
||||
const statusStr = String(value)
|
||||
const color = STATUS_COLORS[statusStr] ?? '#888'
|
||||
if (editingKey === key) {
|
||||
return (
|
||||
<input
|
||||
className="inspector__edit-input"
|
||||
type="text"
|
||||
defaultValue={statusStr}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSaveValue(key, (e.target as HTMLInputElement).value)
|
||||
if (e.key === 'Escape') setEditingKey(null)
|
||||
}}
|
||||
onBlur={(e) => handleSaveValue(key, e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="inspector__status-pill inspector__status-pill--editable"
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={() => setEditingKey(key)}
|
||||
title="Click to edit"
|
||||
>
|
||||
{statusStr}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Arrays get list editor
|
||||
if (Array.isArray(value)) {
|
||||
return (
|
||||
<EditableList
|
||||
items={value.map(String)}
|
||||
onSave={(items) => handleSaveList(key, items)}
|
||||
label={key}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Date fields - still editable but formatted
|
||||
if (key.includes('Created') || key.includes('Modified') || key.includes('time') || key.includes('Date')) {
|
||||
const displayValue = typeof value === 'string' && value.includes('T')
|
||||
? formatISODate(value)
|
||||
: String(value)
|
||||
return (
|
||||
<EditableValue
|
||||
value={String(value)}
|
||||
isEditing={editingKey === key}
|
||||
onStartEdit={() => setEditingKey(key)}
|
||||
onSave={(v) => handleSaveValue(key, v)}
|
||||
onCancel={() => setEditingKey(null)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Boolean
|
||||
if (typeof value === 'boolean') {
|
||||
return (
|
||||
<button
|
||||
className="inspector__bool-toggle"
|
||||
onClick={() => onUpdateProperty?.(key, !value)}
|
||||
>
|
||||
{value ? '✓ Yes' : '✗ No'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Default: editable string
|
||||
return (
|
||||
<EditableValue
|
||||
value={String(value)}
|
||||
isEditing={editingKey === key}
|
||||
onStartEdit={() => setEditingKey(key)}
|
||||
onSave={(v) => handleSaveValue(key, v)}
|
||||
onCancel={() => setEditingKey(null)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="inspector__section">
|
||||
<h4>Properties</h4>
|
||||
<div className="inspector__props">
|
||||
{/* Always show Type from entry */}
|
||||
{entry.isA && (
|
||||
<div className="inspector__prop">
|
||||
<span className="inspector__prop-label">Type</span>
|
||||
<span className="inspector__prop-value">{entry.isA}</span>
|
||||
</div>
|
||||
)}
|
||||
{entry.status && (
|
||||
<div className="inspector__prop">
|
||||
<span className="inspector__prop-label">Status</span>
|
||||
<span
|
||||
className="inspector__status-pill"
|
||||
style={{ backgroundColor: statusColor }}
|
||||
>
|
||||
{entry.status}
|
||||
|
||||
{/* Dynamic properties from frontmatter */}
|
||||
{propertyEntries.map(([key, value]) => (
|
||||
<div key={key} className="inspector__prop">
|
||||
<span className="inspector__prop-label">
|
||||
{key}
|
||||
{onDeleteProperty && (
|
||||
<button
|
||||
className="inspector__prop-delete"
|
||||
onClick={() => onDeleteProperty(key)}
|
||||
title="Delete property"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
{renderEditableValue(key, value)}
|
||||
</div>
|
||||
)}
|
||||
{entry.owner && (
|
||||
<div className="inspector__prop">
|
||||
<span className="inspector__prop-label">Owner</span>
|
||||
<span className="inspector__prop-value">{entry.owner}</span>
|
||||
</div>
|
||||
)}
|
||||
{entry.cadence && (
|
||||
<div className="inspector__prop">
|
||||
<span className="inspector__prop-label">Cadence</span>
|
||||
<span className="inspector__prop-value">{entry.cadence}</span>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
|
||||
{/* Always show Modified and Words (read-only) */}
|
||||
<div className="inspector__prop">
|
||||
<span className="inspector__prop-label">Modified</span>
|
||||
<span className="inspector__prop-value">{formatDate(entry.modifiedAt)}</span>
|
||||
@@ -135,9 +652,41 @@ function PropertiesPanel({ entry, content }: { entry: VaultEntry; content: strin
|
||||
<span className="inspector__prop-value">{wordCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button className="inspector__add-prop" disabled>
|
||||
+ Add property
|
||||
</button>
|
||||
|
||||
{/* Add property UI */}
|
||||
{showAddDialog ? (
|
||||
<div className="inspector__add-dialog">
|
||||
<input
|
||||
className="inspector__edit-input"
|
||||
type="text"
|
||||
placeholder="Property name"
|
||||
value={newKey}
|
||||
onChange={(e) => setNewKey(e.target.value)}
|
||||
onKeyDown={handleAddKeyDown}
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
className="inspector__edit-input"
|
||||
type="text"
|
||||
placeholder="Value"
|
||||
value={newValue}
|
||||
onChange={(e) => setNewValue(e.target.value)}
|
||||
onKeyDown={handleAddKeyDown}
|
||||
/>
|
||||
<div className="inspector__add-dialog-buttons">
|
||||
<button onClick={handleAddProperty} disabled={!newKey.trim()}>Add</button>
|
||||
<button onClick={() => { setShowAddDialog(false); setNewKey(''); setNewValue('') }}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="inspector__add-prop"
|
||||
onClick={() => setShowAddDialog(true)}
|
||||
disabled={!onAddProperty}
|
||||
>
|
||||
+ Add property
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<aside className={`inspector ${collapsed ? 'inspector--collapsed' : ''}`}>
|
||||
<div className="inspector__header">
|
||||
<div className="inspector__header" data-tauri-drag-region>
|
||||
<button className="inspector__toggle" onClick={onToggle}>
|
||||
{collapsed ? '\u25C0' : '\u25B6'}
|
||||
</button>
|
||||
@@ -252,8 +834,15 @@ export function Inspector({ collapsed, onToggle, entry, content, entries, allCon
|
||||
<div className="inspector__content">
|
||||
{entry ? (
|
||||
<>
|
||||
<PropertiesPanel entry={entry} content={content} />
|
||||
<RelationshipsPanel entry={entry} onNavigate={onNavigate} />
|
||||
<DynamicPropertiesPanel
|
||||
entry={entry}
|
||||
content={content}
|
||||
frontmatter={frontmatter}
|
||||
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
|
||||
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
|
||||
onAddProperty={onAddProperty ? handleAddProperty : undefined}
|
||||
/>
|
||||
<DynamicRelationshipsPanel frontmatter={frontmatter} onNavigate={onNavigate} />
|
||||
<BacklinksPanel backlinks={backlinks} onNavigate={onNavigate} />
|
||||
<GitHistoryPanel commits={gitHistory} />
|
||||
</>
|
||||
|
||||
@@ -13,6 +13,13 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
-webkit-app-region: drag;
|
||||
app-region: drag;
|
||||
}
|
||||
|
||||
.note-list__header button {
|
||||
-webkit-app-region: no-drag;
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
.note-list__header h3 {
|
||||
|
||||
@@ -31,6 +31,11 @@ function relativeDate(ts: number | null): string {
|
||||
if (!ts) return ''
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const diff = now - ts
|
||||
if (diff < 0) {
|
||||
// Future date - just show the date
|
||||
const date = new Date(ts * 1000)
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
if (diff < 60) return 'just now'
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
||||
@@ -39,6 +44,12 @@ function relativeDate(ts: number | null): string {
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
/** Get the best date to display for an entry (prefer modifiedAt, fallback to createdAt) */
|
||||
function getDisplayDate(entry: VaultEntry): number | null {
|
||||
// Prefer modifiedAt (most recent activity), but fall back to createdAt
|
||||
return entry.modifiedAt ?? entry.createdAt
|
||||
}
|
||||
|
||||
/** Check if a wikilink array (e.g. belongsTo) references a given entry by path stem */
|
||||
function refsMatch(refs: string[], entry: VaultEntry): boolean {
|
||||
// Extract the path stem: /Users/luca/Laputa/project/26q1-laputa-app.md → project/26q1-laputa-app
|
||||
@@ -84,7 +95,7 @@ function filterEntries(entries: VaultEntry[], selection: SidebarSelection): Vaul
|
||||
}
|
||||
|
||||
function sortByModified(a: VaultEntry, b: VaultEntry): number {
|
||||
return (b.modifiedAt ?? 0) - (a.modifiedAt ?? 0)
|
||||
return (getDisplayDate(b) ?? 0) - (getDisplayDate(a) ?? 0)
|
||||
}
|
||||
|
||||
const TYPE_PILLS = [
|
||||
@@ -126,7 +137,7 @@ export function NoteList({ entries, selection, selectedNote, allContent, onSelec
|
||||
|
||||
return (
|
||||
<div className="note-list">
|
||||
<div className="note-list__header">
|
||||
<div className="note-list__header" data-tauri-drag-region>
|
||||
<h3>Notes</h3>
|
||||
<div className="note-list__header-right">
|
||||
<span className="note-list__count">{displayed.length}</span>
|
||||
@@ -169,7 +180,7 @@ export function NoteList({ entries, selection, selectedNote, allContent, onSelec
|
||||
>
|
||||
<div className="note-list__item-top">
|
||||
<div className="note-list__title">{entry.title}</div>
|
||||
<span className="note-list__date">{relativeDate(entry.modifiedAt)}</span>
|
||||
<span className="note-list__date">{relativeDate(getDisplayDate(entry))}</span>
|
||||
</div>
|
||||
<div className="note-list__snippet">{getSnippet(allContent[entry.path])}</div>
|
||||
<div className="note-list__meta">
|
||||
|
||||
@@ -8,9 +8,12 @@
|
||||
}
|
||||
|
||||
.sidebar__header {
|
||||
padding: 20px 16px 16px;
|
||||
padding: 12px 16px;
|
||||
padding-left: 78px; /* space for macOS traffic light buttons */
|
||||
border-bottom: 1px solid #2a2a4a;
|
||||
flex-shrink: 0;
|
||||
-webkit-app-region: drag;
|
||||
app-region: drag;
|
||||
}
|
||||
|
||||
.sidebar__header h2 {
|
||||
@@ -21,6 +24,11 @@
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.sidebar__header button {
|
||||
-webkit-app-region: no-drag;
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
.sidebar__nav {
|
||||
padding: 8px 0;
|
||||
flex: 1;
|
||||
|
||||
@@ -6,6 +6,7 @@ interface SidebarProps {
|
||||
entries: VaultEntry[]
|
||||
selection: SidebarSelection
|
||||
onSelect: (selection: SidebarSelection) => void
|
||||
onSelectNote?: (entry: VaultEntry) => void
|
||||
}
|
||||
|
||||
const FILTERS = [
|
||||
@@ -23,7 +24,7 @@ const SECTION_GROUPS = [
|
||||
{ label: 'PROCEDURES', type: 'Procedure' },
|
||||
] as const
|
||||
|
||||
export function Sidebar({ entries, selection, onSelect }: SidebarProps) {
|
||||
export function Sidebar({ entries, selection, onSelect, onSelectNote }: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
|
||||
|
||||
const toggleSection = (type: string) => {
|
||||
@@ -41,7 +42,7 @@ export function Sidebar({ entries, selection, onSelect }: SidebarProps) {
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar__header">
|
||||
<div className="sidebar__header" data-tauri-drag-region>
|
||||
<h2>Laputa</h2>
|
||||
</div>
|
||||
|
||||
@@ -103,7 +104,10 @@ export function Sidebar({ entries, selection, onSelect }: SidebarProps) {
|
||||
className={`sidebar__item${
|
||||
isActive({ kind: 'entity', entry }) ? ' sidebar__item--active' : ''
|
||||
}`}
|
||||
onClick={() => onSelect({ kind: 'entity', entry })}
|
||||
onClick={() => {
|
||||
onSelect({ kind: 'entity', entry })
|
||||
onSelectNote?.(entry)
|
||||
}}
|
||||
>
|
||||
{entry.title}
|
||||
</div>
|
||||
@@ -126,7 +130,10 @@ export function Sidebar({ entries, selection, onSelect }: SidebarProps) {
|
||||
className={`sidebar__topic-item${
|
||||
isActive({ kind: 'topic', entry }) ? ' sidebar__topic-item--active' : ''
|
||||
}`}
|
||||
onClick={() => onSelect({ kind: 'topic', entry })}
|
||||
onClick={() => {
|
||||
onSelect({ kind: 'topic', entry })
|
||||
onSelectNote?.(entry)
|
||||
}}
|
||||
>
|
||||
{entry.title}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user