Skip to content

Commit fa2f5d6

Browse files
committed
Fix IPC serialization and tooltip lock improvements
- Fix database to JSON stringify all arrays/objects, not just tags - Add IPC data serialization in api.ts to prevent cloning errors - Use toRaw() in DetailPanel to unwrap Vue proxies before emit - Add null container check in cytoscape init to prevent HMR crash - Add try/catch to broadcast functions in useDetachedWindow - Improve tooltip lock to show immediately on click - Add Space/Escape key dismissal for locked tooltips - Fix workspace settings persistence with loose equality comparison
1 parent 1b8f4d5 commit fa2f5d6

10 files changed

Lines changed: 188 additions & 87 deletions

File tree

docs/reference/interactions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Hovering over a node displays a preview tooltip with the node's title, metadata,
2222
| Hover | Show tooltip after 500ms delay |
2323
| Move away | Hide tooltip after 200ms delay |
2424
| Click node | Lock tooltip in place, enable scrolling |
25-
| Space | Open detail panel, dismiss tooltip |
25+
| Space | Dismiss locked tooltip |
2626
| Escape | Dismiss locked tooltip |
2727
| Click elsewhere | Dismiss locked tooltip |
2828

electron/database/nodes.js

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -135,10 +135,14 @@ function createNodeOperations(ctx) {
135135
createNode(data) {
136136
const presentFields = NODE_FIELDS.filter(f => data[f] !== undefined)
137137
const values = presentFields.map(f => {
138-
if (f === 'tags' && Array.isArray(data[f])) {
139-
return JSON.stringify(data[f])
138+
const val = data[f]
139+
// Arrays and objects need to be JSON stringified for SQLite
140+
if (Array.isArray(val)) {
141+
return JSON.stringify(val)
142+
} else if (typeof val === 'object' && val !== null) {
143+
return JSON.stringify(val)
140144
}
141-
return data[f]
145+
return val
142146
})
143147

144148
let depth = 0
@@ -176,7 +180,11 @@ function createNodeOperations(ctx) {
176180
for (const field of NODE_FIELDS) {
177181
if (data[field] !== undefined) {
178182
updates.push(`${field} = ?`)
179-
if (field === 'tags' && Array.isArray(data[field])) {
183+
// Arrays need to be JSON stringified for SQLite
184+
if (Array.isArray(data[field])) {
185+
values.push(JSON.stringify(data[field]))
186+
} else if (typeof data[field] === 'object' && data[field] !== null) {
187+
// Objects also need to be stringified
180188
values.push(JSON.stringify(data[field]))
181189
} else {
182190
values.push(data[field])

src/components/DetailPanel.vue

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup>
2-
import { ref, watch, computed, nextTick, onMounted, onUnmounted } from 'vue'
2+
import { ref, watch, computed, nextTick, onMounted, onUnmounted, toRaw } from 'vue'
33
import MarkdownRenderer from './MarkdownRenderer.vue'
44
import MentionDropdown from './MentionDropdown.vue'
55
import NotesEditor from './NotesEditor.vue'
@@ -330,13 +330,18 @@ function saveChanges() {
330330
clearTimeout(notesAutosaveTimeout)
331331
notesAutosaveTimeout = null
332332
}
333-
emit('update', editedNode.value)
333+
// Use toRaw to unwrap Vue proxy before emitting (prevents IPC cloning errors)
334+
emit('update', { ...toRaw(editedNode.value) })
334335
}
335336
336337
// Toggle handlers for template
337338
function toggleFavorite() {
338-
editedNode.value.favorite = !editedNode.value.favorite
339-
saveChanges()
339+
try {
340+
editedNode.value.favorite = !editedNode.value.favorite
341+
saveChanges()
342+
} catch (e) {
343+
console.error('toggleFavorite failed:', e)
344+
}
340345
}
341346
342347
function onCompletedChange(event) {

src/components/GraphView.vue

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,8 @@ const showRootNode = ref(
119119
props.parent?.show_root_node != null ? Boolean(props.parent.show_root_node) : _showRootNode.value
120120
)
121121
const getWorkspaceShowExternalLinks = () => {
122-
const ws = props.workspaces.find(w => w.id === props.workspace)
122+
// Compare with == to handle string/number mismatch (props.workspace is a string, w.id is a number)
123+
const ws = props.workspaces.find(w => w.id == props.workspace)
123124
return ws?.show_external_links != null ? Boolean(ws.show_external_links) : _showExternalLinks.value
124125
}
125126
const showExternalLinks = ref(
@@ -150,6 +151,7 @@ const {
150151
hideTooltip,
151152
forceHide: forceHideTooltip,
152153
toggleLock: toggleTooltipLock,
154+
isLocked: isTooltipLocked,
153155
} = useNodeTooltip({
154156
onToggleComplete: id => {
155157
const node =
@@ -422,6 +424,19 @@ watch(
422424
}
423425
)
424426
427+
// Update settings when workspaces load (workspace data may have settings overrides)
428+
watch(
429+
() => props.workspaces,
430+
() => {
431+
if (!props.parent && props.workspaces.length > 0) {
432+
const wsShowExtLinks = getWorkspaceShowExternalLinks()
433+
if (showExternalLinks.value !== wsShowExtLinks) {
434+
showExternalLinks.value = wsShowExtLinks
435+
}
436+
}
437+
}
438+
)
439+
425440
watch(showExternalLinks, () => {
426441
if (cy) {
427442
_savePos()
@@ -499,6 +514,16 @@ function handleGlobalKeydown(e) {
499514
ids.length === 1 ? emit('delete', ids[0]) : ids.length > 1 && emit('delete-multiple', ids)
500515
}
501516
}
517+
// Space or Escape key dismisses locked tooltip
518+
if (
519+
(e.key === ' ' || e.key === 'Escape') &&
520+
!inModal &&
521+
!['INPUT', 'TEXTAREA'].includes(e.target.tagName) &&
522+
isTooltipLocked()
523+
) {
524+
e.preventDefault()
525+
forceHideTooltip()
526+
}
502527
if ((e.metaKey || e.ctrlKey) && !inModal && !['INPUT', 'TEXTAREA'].includes(e.target.tagName)) {
503528
if (e.key === 'ArrowUp') {
504529
e.preventDefault()
@@ -531,6 +556,10 @@ async function initGraph() {
531556
const hasPos = Object.keys(savedPos).length > 0
532557
533558
cy = graphInit.createCytoscapeInstance(elements, hasPos)
559+
if (!cy) {
560+
isInitializing = false
561+
return
562+
}
534563
cy.nodes().grabify()
535564
graphInit.setupHtmlLabels(cy)
536565
wheel.setupWheelHandler()

src/composables/useDetachedWindow.js

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -66,20 +66,31 @@ export function useDetachedWindow() {
6666
// Broadcast node deletion to other windows
6767
function broadcastNodeDelete(nodeId) {
6868
if (channel.value) {
69-
channel.value.postMessage({
70-
type: 'node-deleted',
71-
nodeId: nodeId,
72-
})
69+
try {
70+
// Ensure nodeId is a primitive (not a Vue ref)
71+
const id = typeof nodeId === 'object' && nodeId !== null ? (nodeId.value ?? nodeId) : nodeId
72+
channel.value.postMessage({
73+
type: 'node-deleted',
74+
nodeId: id,
75+
})
76+
} catch (e) {
77+
console.warn('Failed to broadcast node delete:', e)
78+
}
7379
}
7480
}
7581

7682
// Broadcast navigation change (for detached windows)
7783
function broadcastNavigation(nodeId) {
7884
if (channel.value) {
79-
channel.value.postMessage({
80-
type: 'navigate',
81-
nodeId: nodeId,
82-
})
85+
try {
86+
const id = typeof nodeId === 'object' && nodeId !== null ? (nodeId.value ?? nodeId) : nodeId
87+
channel.value.postMessage({
88+
type: 'navigate',
89+
nodeId: id,
90+
})
91+
} catch (e) {
92+
console.warn('Failed to broadcast navigation:', e)
93+
}
8394
}
8495
}
8596

src/composables/useGraphEvents.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ export function useGraphEvents(options = {}) {
118118
emit('select-multiple', { node, add: true })
119119
} else {
120120
// Toggle tooltip lock when clicking without modifiers
121-
if (toggleTooltipLock) toggleTooltipLock(node)
121+
if (toggleTooltipLock) toggleTooltipLock(node, e.originalEvent)
122122
emit('select', node)
123123
}
124124
})

src/composables/useGraphInit.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,14 @@ export function useGraphInit(options = {}) {
7676
* Create the cytoscape instance with configuration.
7777
* @param {Array} elements - Graph elements (nodes and edges)
7878
* @param {boolean} hasPos - Whether saved positions exist
79-
* @returns {Object} Cytoscape instance
79+
* @returns {Object|null} Cytoscape instance or null if container not available
8080
*/
8181
function createCytoscapeInstance(elements, hasPos) {
8282
const container = getContainer()
83+
if (!container) {
84+
console.warn('Cytoscape container not available')
85+
return null
86+
}
8387
const layoutOptions = getLayoutOptions()
8488

8589
return cytoscape({

src/composables/useNodeActionsUI.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -401,14 +401,19 @@ export function useNodeActionsUI({
401401
* Update a node with full UI refresh.
402402
*/
403403
async function updateNode(updatedNode: Partial<Node> & { id: number }, trackUndo: boolean = true): Promise<boolean> {
404-
const success = await nodeOps.updateNode(updatedNode, { trackUndo })
405-
if (success) {
406-
await loadChildren(currentContainerId.value, { silent: true })
407-
invalidateSidebarCache()
408-
await loadSidebarTree()
409-
await Promise.all([loadRecentItems(), loadFavorites(), loadTags()])
404+
try {
405+
const success = await nodeOps.updateNode(updatedNode, { trackUndo })
406+
if (success) {
407+
await loadChildren(currentContainerId.value, { silent: true })
408+
invalidateSidebarCache()
409+
await loadSidebarTree()
410+
await Promise.all([loadRecentItems(), loadFavorites(), loadTags()])
411+
}
412+
return success
413+
} catch (e) {
414+
console.error('updateNode failed:', e)
415+
throw e
410416
}
411-
return success
412417
}
413418

414419
/**

src/composables/useNodeTooltip.js

Lines changed: 90 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,75 @@ export function useNodeTooltip(options = {}) {
2222
const TOOLTIP_DELAY = 500
2323
const HIDE_DELAY = 200 // Allow time to move mouse to tooltip
2424

25+
/**
26+
* Create and show tooltip immediately for a node.
27+
* @param {Object} node - Node data
28+
* @param {Event|null} event - Mouse event for positioning (optional)
29+
*/
30+
function createTooltip(node, event = null) {
31+
// Destroy existing tooltip
32+
if (activeTooltip) {
33+
activeTooltip.destroy()
34+
activeTooltip = null
35+
}
36+
37+
activeNodeId = node.id
38+
39+
const content = buildTooltipHTML(node, {
40+
showCheckbox: node.type === 'task',
41+
hideSensitive: getHideSensitive(),
42+
})
43+
44+
// Use dynamic position reference based on cursor location
45+
const fixedRef = getFixedTooltipReference(event)
46+
const placement = getTooltipPlacement(event)
47+
48+
activeTooltip = tippy(fixedRef, {
49+
...tooltipOptions,
50+
placement,
51+
content,
52+
showOnCreate: true,
53+
hideOnClick: false,
54+
onHidden: instance => {
55+
instance.destroy()
56+
if (activeTooltip === instance) {
57+
activeTooltip = null
58+
}
59+
},
60+
onShown: instance => {
61+
// Hide tooltip when mouse enters it (unless locked)
62+
instance.popper.addEventListener('mouseenter', () => {
63+
if (!instance.state.isDestroyed && !locked) {
64+
instance.hide()
65+
}
66+
})
67+
68+
// Attach checkbox listener
69+
const checkbox = instance.popper.querySelector('input[type="checkbox"][data-node-id]')
70+
if (checkbox && onToggleComplete) {
71+
checkbox.addEventListener('change', evt => {
72+
const nodeId = parseInt(evt.target.dataset.nodeId)
73+
onToggleComplete(nodeId)
74+
if (!instance.state.isDestroyed) {
75+
instance.hide()
76+
}
77+
})
78+
}
79+
// Attach open detail button listener
80+
const openBtn = instance.popper.querySelector('.tt-open-detail[data-node-id]')
81+
if (openBtn && onOpenDetail) {
82+
openBtn.addEventListener('click', evt => {
83+
const nodeId = parseInt(evt.target.dataset.nodeId)
84+
onOpenDetail(nodeId)
85+
if (!instance.state.isDestroyed) {
86+
instance.hide()
87+
}
88+
})
89+
}
90+
},
91+
})
92+
}
93+
2594
function showTooltip(event, node) {
2695
// Check if tooltip should be shown for this node
2796
if (!shouldShowTooltip(node)) {
@@ -55,61 +124,7 @@ export function useNodeTooltip(options = {}) {
55124
return
56125
}
57126

58-
activeNodeId = node.id
59-
60-
const content = buildTooltipHTML(node, {
61-
showCheckbox: node.type === 'task',
62-
hideSensitive: getHideSensitive(),
63-
})
64-
65-
// Use dynamic position reference based on cursor location
66-
const fixedRef = getFixedTooltipReference(storedEvent)
67-
const placement = getTooltipPlacement(storedEvent)
68-
69-
activeTooltip = tippy(fixedRef, {
70-
...tooltipOptions,
71-
placement,
72-
content,
73-
showOnCreate: true,
74-
hideOnClick: false,
75-
onHidden: instance => {
76-
instance.destroy()
77-
if (activeTooltip === instance) {
78-
activeTooltip = null
79-
}
80-
},
81-
onShown: instance => {
82-
// Hide tooltip when mouse enters it
83-
instance.popper.addEventListener('mouseenter', () => {
84-
if (!instance.state.isDestroyed) {
85-
instance.hide()
86-
}
87-
})
88-
89-
// Attach checkbox listener
90-
const checkbox = instance.popper.querySelector('input[type="checkbox"][data-node-id]')
91-
if (checkbox && onToggleComplete) {
92-
checkbox.addEventListener('change', evt => {
93-
const nodeId = parseInt(evt.target.dataset.nodeId)
94-
onToggleComplete(nodeId)
95-
if (!instance.state.isDestroyed) {
96-
instance.hide()
97-
}
98-
})
99-
}
100-
// Attach open detail button listener
101-
const openBtn = instance.popper.querySelector('.tt-open-detail[data-node-id]')
102-
if (openBtn && onOpenDetail) {
103-
openBtn.addEventListener('click', evt => {
104-
const nodeId = parseInt(evt.target.dataset.nodeId)
105-
onOpenDetail(nodeId)
106-
if (!instance.state.isDestroyed) {
107-
instance.hide()
108-
}
109-
})
110-
}
111-
},
112-
})
127+
createTooltip(node, storedEvent)
113128
}, TOOLTIP_DELAY)
114129
}
115130

@@ -169,13 +184,30 @@ export function useNodeTooltip(options = {}) {
169184
activeNodeId = null
170185
}
171186

172-
function toggleLock(node) {
173-
if (locked && activeNodeId === node?.id) {
187+
function toggleLock(node, event = null) {
188+
if (!node) return
189+
190+
// Clear any pending show/hide
191+
if (tooltipShowTimeout) {
192+
clearTimeout(tooltipShowTimeout)
193+
tooltipShowTimeout = null
194+
}
195+
if (tooltipHideTimeout) {
196+
clearTimeout(tooltipHideTimeout)
197+
tooltipHideTimeout = null
198+
}
199+
200+
if (locked && activeNodeId === node.id) {
174201
// Clicking same node again - unlock
175202
unlockTooltip()
176-
} else if (activeTooltip && !activeTooltip.state.isDestroyed && activeNodeId === node?.id) {
203+
} else if (activeTooltip && !activeTooltip.state.isDestroyed && activeNodeId === node.id) {
177204
// Tooltip visible for this node - lock it
178205
lockTooltip()
206+
} else if (shouldShowTooltip(node)) {
207+
// No tooltip visible - create one immediately and lock it
208+
createTooltip(node, event)
209+
// Lock after a brief delay to ensure tooltip is shown
210+
setTimeout(() => lockTooltip(), 10)
179211
}
180212
}
181213

0 commit comments

Comments
 (0)