From 51001195fb56c3fbc8f2e7da5fd549b19b0e8393 Mon Sep 17 00:00:00 2001 From: Ioannis Kechagias Date: Tue, 29 Sep 2026 03:08:15 +0300 Subject: [PATCH 01/20] feat(desktop): link Markdown files and folders alongside Vault copies --- desktop-app/neutralino.config.json | 4 + desktop-app/resources/index.html | 10 +- desktop-app/resources/js/main.js | 13 +- desktop-app/resources/js/script.js | 781 +++++++++++++++++- desktop-app/resources/js/workspace-storage.js | 1 + desktop-app/resources/styles.css | 14 + index.html | 10 +- script.js | 781 +++++++++++++++++- styles.css | 14 + tests/e2e/desktop-linked-file.spec.js | 142 ++++ workspace-storage.js | 1 + 11 files changed, 1721 insertions(+), 50 deletions(-) create mode 100644 tests/e2e/desktop-linked-file.spec.js diff --git a/desktop-app/neutralino.config.json b/desktop-app/neutralino.config.json index 7239f796..3dcc68e5 100644 --- a/desktop-app/neutralino.config.json +++ b/desktop-app/neutralino.config.json @@ -18,6 +18,7 @@ "nativeAllowList": [ "app.exit", "os.showOpenDialog", + "os.showFolderDialog", "os.showSaveDialog", "os.showMessageBox", "os.getPath", @@ -34,6 +35,9 @@ "filesystem.copy", "filesystem.move", "filesystem.remove", + "filesystem.createWatcher", + "filesystem.removeWatcher", + "filesystem.getWatchers", "storage.setData", "storage.getData", "storage.getKeys", diff --git a/desktop-app/resources/index.html b/desktop-app/resources/index.html index 3cfcbe2b..1e161f3e 100644 --- a/desktop-app/resources/index.html +++ b/desktop-app/resources/index.html @@ -72,7 +72,9 @@

Markdown Viewer - Online Markd @@ -346,6 +348,12 @@

Explorer

+ + diff --git a/desktop-app/resources/js/main.js b/desktop-app/resources/js/main.js index 7e92a51d..8833c71b 100644 --- a/desktop-app/resources/js/main.js +++ b/desktop-app/resources/js/main.js @@ -131,6 +131,14 @@ if (isNeutralinoRuntime()) { // TODO: Fix https://github.com/neutralinojs/neutralinojs/issues/615 setTray(); } + + window.addEventListener('markdown-viewer:ready', function() { + if (typeof window.NL_START_LINKED_MONITORING === 'function') { + window.NL_START_LINKED_MONITORING().catch(function(error) { + console.warn('Linked source monitoring initialization failed:', error); + }); + } + }, { once: true }); } // Open file passed as command-line argument (e.g. when double-clicking a .md file) @@ -146,12 +154,13 @@ if (isNeutralinoRuntime()) { window.NL_INITIAL_FILE_CONTENT = { name: fileName, - content: content + content: content, + sourcePath: filePath }; // Callback hook in case script.js loaded first if (window.NL_IMPORT_EXTERNAL_FILE) { - window.NL_IMPORT_EXTERNAL_FILE(content, fileName); + window.NL_IMPORT_EXTERNAL_FILE(content, fileName, filePath); } } catch (e) { console.warn('Could not open initial file:', e); diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index 133e6911..670dabda 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -361,6 +361,8 @@ document.addEventListener("DOMContentLoaded", async function () { const themeSwitchIcon = document.getElementById("theme-switch-icon"); const directionToggle = document.getElementById("direction-toggle"); const importFromFileButton = document.getElementById("import-from-file"); + const linkFromFileButton = document.getElementById("link-from-file"); + const linkFromFolderButton = document.getElementById("link-from-folder"); const importFromGithubButton = document.getElementById("import-from-github"); const fileInput = document.getElementById("file-input"); const exportDropdown = document.getElementById("exportDropdown"); @@ -4207,7 +4209,7 @@ document.addEventListener("DOMContentLoaded", async function () { const UNTITLED_COUNTER_KEY = 'markdownViewerUntitledCounter'; const DOCUMENT_ORGANIZATION_KEY = 'markdownViewerDocumentOrganization'; const SECRET_WORKSPACE_STORAGE_KEY = 'markdownViewerSecretWorkspace'; - const DOCUMENT_ORGANIZATION_VERSION = 3; + const DOCUMENT_ORGANIZATION_VERSION = 4; const SECRET_WORKSPACE_VERSION = 2; const SECRET_KDF_ITERATIONS = 250000; const DEFAULT_WORKSPACE_ID = 'workspace_default'; @@ -4235,6 +4237,9 @@ document.addEventListener("DOMContentLoaded", async function () { let documentTreeDragExpandRow = null; let documentTreeAutoScrollFrame = null; let documentTreeAutoScrollSpeed = 0; + const linkedSourceWatchers = new Map(); + const linkedSourceRefreshTimers = new Map(); + let linkedSourceMonitoringReady = false; let secretWorkspaceKey = null; let secretWorkspaceSalt = null; let secretWorkspaceIterations = SECRET_KDF_ITERATIONS; @@ -4277,7 +4282,7 @@ document.addEventListener("DOMContentLoaded", async function () { workspaces: [ { id: DEFAULT_WORKSPACE_ID, - name: 'Workspace', + name: isNeutralinoRuntimeAvailable() ? 'Workspace (Vault)' : 'Workspace', expanded: true, createdAt: 0 }, @@ -4289,6 +4294,7 @@ document.addEventListener("DOMContentLoaded", async function () { } ], folders: [], + linkedLocations: [], ui: { filter: 'all', collapsed: false, @@ -4349,6 +4355,27 @@ document.addEventListener("DOMContentLoaded", async function () { } }); + const seenLinkedLocationIds = new Set(); + const linkedLocations = (Array.isArray(source.linkedLocations) ? source.linkedLocations : []).map(function(location) { + if (!location || typeof location !== 'object') return null; + const id = String(location.id || '').slice(0, 120); + const path = String(location.path || '').trim(); + if (!id || !path || seenLinkedLocationIds.has(id)) return null; + seenLinkedLocationIds.add(id); + return { + id: id, + path: path, + name: String(location.name || '').trim().slice(0, 160) || path.split(/[/\\]/).filter(Boolean).pop() || 'Linked folder', + expanded: location.expanded !== false, + excludedPaths: Array.from(new Set((Array.isArray(location.excludedPaths) ? location.excludedPaths : []) + .map(function(item) { return String(item || '').replace(/\\/g, '/').replace(/^\/+/, ''); }) + .filter(Boolean))), + createdAt: Number.isFinite(Number(location.createdAt)) ? Number(location.createdAt) : Date.now(), + lastScanAt: Number.isFinite(Number(location.lastScanAt)) ? Number(location.lastScanAt) : 0, + unavailable: location.unavailable === true + }; + }).filter(Boolean); + const ui = source.ui && typeof source.ui === 'object' ? source.ui : {}; const allowedFilters = new Set(['all', 'recent', 'favorites']); const width = Number(ui.width); @@ -4360,6 +4387,7 @@ document.addEventListener("DOMContentLoaded", async function () { version: DOCUMENT_ORGANIZATION_VERSION, workspaces: workspaces, folders: folders, + linkedLocations: linkedLocations, ui: { filter: allowedFilters.has(ui.filter) ? ui.filter : 'all', collapsed: ui.collapsed === true, @@ -4417,6 +4445,18 @@ document.addEventListener("DOMContentLoaded", async function () { folders: documentOrganization.folders.filter(function(folder) { return folder.workspaceId !== SECRET_WORKSPACE_ID; }), + linkedLocations: (documentOrganization.linkedLocations || []).map(function(location) { + return { + id: location.id, + path: location.path, + name: location.name, + expanded: location.expanded !== false, + excludedPaths: Array.isArray(location.excludedPaths) ? location.excludedPaths.slice() : [], + createdAt: location.createdAt, + lastScanAt: location.lastScanAt || 0, + unavailable: location.unavailable === true + }; + }), ui: Object.assign({}, documentOrganization.ui, { lastWorkspaceId: documentOrganization.ui.lastWorkspaceId === SECRET_WORKSPACE_ID && !secretWorkspaceKey ? DEFAULT_WORKSPACE_ID @@ -5849,6 +5889,18 @@ document.addEventListener("DOMContentLoaded", async function () { } function getDocumentMenuActions(tab) { + if (tab && tab.sourcePath && !isTemporaryDocument(tab)) { + const linkedActions = [ + { id: 'open', icon: 'lucide-file-text', label: 'Open', run: function() { openSidebarDocument(tab.id); } }, + { id: 'reload-source', icon: 'lucide-refresh-cw', label: 'Reload from disk', run: function() { void reloadLinkedDocument(tab.id, { force: true }); } }, + { id: 'reveal-source', icon: 'lucide-folder-open', label: 'Open containing folder', run: function() { void revealLinkedSource(tab); } }, + { id: 'copy-source-path', icon: 'lucide-copy', label: 'Copy source path', run: function() { void copyTextToClipboard(tab.sourcePath); } }, + { separator: true }, + { id: 'convert-vault', icon: 'lucide-files', label: 'Convert to Workspace copy', run: function() { void convertLinkedDocumentToVault(tab.id); } }, + { id: 'remove-link', icon: 'lucide-x', label: 'Remove link from Workspace', run: function() { void removeLinkedDocument(tab.id); } } + ]; + return linkedActions; + } const actions = [ { id: 'open', icon: 'lucide-file-text', label: 'Open', run: function() { openSidebarDocument(tab.id); } }, { id: 'rename', icon: 'lucide-square-pen', label: 'Rename', run: function() { renameTab(tab.id); } @@ -5929,6 +5981,10 @@ document.addEventListener("DOMContentLoaded", async function () { const workspace = getWorkspaceById(id); return workspace ? getWorkspaceMenuActions(workspace) : []; } + if (type === 'linked-location') { + const location = (documentOrganization.linkedLocations || []).find(function(item) { return item.id === id; }); + return location ? getLinkedLocationMenuActions(location) : []; + } return []; } @@ -6517,7 +6573,7 @@ document.addEventListener("DOMContentLoaded", async function () { dragOverlay.setAttribute('aria-hidden', 'true'); } const importFiles = function() { - handleIncomingDroppedFiles(files, { location: location }); + handleIncomingDroppedFiles(files, { location: location, source: 'drop' }); }; if (location.workspaceId === SECRET_WORKSPACE_ID && !isSecretWorkspaceUnlocked()) { withUnlockedSecretWorkspace(importFiles); @@ -6672,8 +6728,8 @@ document.addEventListener("DOMContentLoaded", async function () { id: tab.id, documentId: tab.id, label: tab.title || 'Untitled', - ariaLabel: (tab.id === activeTabId ? 'Active document, ' : 'Document, ') + (tab.title || 'Untitled') + (tab.favorite ? ', favorite' : ''), - icon: 'lucide-file-text', + ariaLabel: (tab.id === activeTabId ? 'Active document, ' : 'Document, ') + (tab.title || 'Untitled') + (tab.sourcePath ? ', linked file' : '') + (tab.favorite ? ', favorite' : ''), + icon: tab.sourcePath ? 'lucide-link-2' : 'lucide-file-text', depth: depth, favorite: tab.favorite === true, temporary: isTemporaryDocument(tab), @@ -6683,6 +6739,12 @@ document.addEventListener("DOMContentLoaded", async function () { openSidebarDocument(tab.id); } }); + if (tab.sourcePath) { + row.classList.add('is-linked-document'); + row.title = tab.sourcePath; + } + if (tab.sourceMissing) row.classList.add('is-source-missing'); + if (tab.sourceConflict) row.classList.add('is-source-conflict'); container.appendChild(row); if (consumesRenderBudget) { documentSidebarRenderBudget.remaining = Math.max(0, documentSidebarRenderBudget.remaining - 1); @@ -6691,6 +6753,7 @@ document.addEventListener("DOMContentLoaded", async function () { } function getDocumentLocationLabel(tab) { + if (tab && tab.sourcePath) return 'Linked · ' + tab.sourcePath; const workspace = getWorkspaceById(tab.workspaceId); const folder = getFolderById(tab.folderId); if (folder && workspace) return workspace.name + ' / ' + getFolderPath(folder.id); @@ -6699,10 +6762,17 @@ document.addEventListener("DOMContentLoaded", async function () { function getDocumentTabHoverTitle(tab) { const title = tab && tab.title ? tab.title : 'Untitled'; + if (tab && tab.sourcePath) return title + ' — Linked file: ' + tab.sourcePath; const folder = tab ? getFolderById(tab.folderId) : null; return folder ? getFolderPath(folder.id) + ' / ' + title : title; } + if (isNeutralinoRuntimeAvailable()) { + document.querySelectorAll('.desktop-linked-command').forEach(function(element) { + element.hidden = false; + }); + } + function documentMatchesSidebarSearch(tab) { if (!documentSidebarSearch) return true; const haystack = ((tab.title || '') + ' ' + getDocumentLocationLabel(tab)).toLocaleLowerCase(); @@ -6727,6 +6797,136 @@ document.addEventListener("DOMContentLoaded", async function () { return renderedDocuments; } + function getLinkedDocumentMeta(tab) { + if (tab.sourceMissing) return 'Missing source'; + if (tab.sourceConflict) return 'Conflict'; + return 'Linked · recovery in Vault'; + } + + function getLinkedLocationMenuActions(location) { + return [ + { id: 'rescan', icon: 'lucide-refresh-cw', label: 'Rescan folder', run: function() { void reconcileLinkedLocation(location.id, { notify: true }); } }, + { id: 'reveal', icon: 'lucide-folder-open', label: 'Open folder', run: function() { void Neutralino.os.open(location.path); } }, + { id: 'unlink', icon: 'lucide-x', label: 'Remove linked folder', run: function() { void removeLinkedLocation(location.id); } } + ]; + } + + function renderLinkedLocationsTree(tree) { + if (typeof Neutralino === 'undefined') return 0; + const linkedTabs = tabs.filter(function(tab) { + return !isTemporaryDocument(tab) && Boolean(tab.sourcePath); + }); + const locations = Array.isArray(documentOrganization.linkedLocations) ? documentOrganization.linkedLocations : []; + if (!linkedTabs.length && !locations.length) return 0; + + let renderedDocuments = 0; + const sectionLabel = document.createElement('p'); + sectionLabel.className = 'document-tree-section-label'; + sectionLabel.textContent = 'Linked Locations'; + tree.appendChild(sectionLabel); + + const individualTabs = linkedTabs.filter(function(tab) { return !tab.linkedRootId; }) + .filter(documentMatchesSidebarSearch) + .sort(function(left, right) { return (left.title || '').localeCompare(right.title || ''); }); + if (individualTabs.length) { + const individualRow = createDocumentTreeRow({ + type: 'linked-location', + id: 'linked-files', + label: 'Linked files', + ariaLabel: 'Linked files, original files remain on disk', + icon: 'lucide-link-2', + depth: 0, + expanded: true, + meta: String(individualTabs.length), + onToggle: function() {} + }); + tree.appendChild(individualRow); + const group = document.createElement('div'); + group.className = 'document-tree-group document-tree-group--workspace'; + group.setAttribute('role', 'group'); + individualTabs.forEach(function(tab) { + if (appendDocumentTreeItem(group, tab, 1, getLinkedDocumentMeta(tab))) renderedDocuments++; + }); + tree.appendChild(group); + } + + locations.slice().sort(function(left, right) { + return (left.name || '').localeCompare(right.name || ''); + }).forEach(function(location) { + const locationTabs = linkedTabs.filter(function(tab) { return tab.linkedRootId === location.id; }); + const matchingTabs = locationTabs.filter(documentMatchesSidebarSearch); + const locationMatches = !documentSidebarSearch || (location.name + ' ' + location.path).toLocaleLowerCase().includes(documentSidebarSearch.toLocaleLowerCase()); + if (documentSidebarSearch && !locationMatches && !matchingTabs.length) return; + const expanded = documentSidebarSearch ? true : location.expanded !== false; + const rootRow = createDocumentTreeRow({ + type: 'linked-location', + id: location.id, + label: location.name, + ariaLabel: 'Linked folder, ' + location.path + (location.unavailable ? ', unavailable' : ''), + icon: location.unavailable ? 'lucide-triangle-alert' : (expanded ? 'lucide-folder-open' : 'lucide-folder'), + depth: 0, + expanded: expanded, + meta: location.unavailable ? 'Unavailable' : String(locationTabs.length), + menuActions: getLinkedLocationMenuActions(location), + onToggle: function() { + location.expanded = !expanded; + saveDocumentOrganization(); + renderDocumentSidebar(); + }, + onActivate: function() { + location.expanded = true; + saveDocumentOrganization(); + renderDocumentSidebar(); + } + }); + tree.appendChild(rootRow); + + const rootGroup = document.createElement('div'); + rootGroup.className = 'document-tree-group document-tree-group--workspace'; + rootGroup.setAttribute('role', 'group'); + rootGroup.hidden = !expanded; + const rootNode = { folders: new Map(), documents: [] }; + matchingTabs.forEach(function(tab) { + const parts = String(tab.sourceRelativePath || '').replace(/\\/g, '/').split('/').filter(Boolean); + if (parts.length) parts.pop(); + let node = rootNode; + parts.forEach(function(part) { + if (!node.folders.has(part)) node.folders.set(part, { folders: new Map(), documents: [] }); + node = node.folders.get(part); + }); + node.documents.push(tab); + }); + const appendVirtualNode = function(node, container, depth, pathParts) { + Array.from(node.folders.entries()).sort(function(left, right) { return left[0].localeCompare(right[0]); }).forEach(function(entry) { + const folderName = entry[0]; + const child = entry[1]; + const row = createDocumentTreeRow({ + type: 'linked-virtual-folder', + id: location.id + ':' + pathParts.concat(folderName).join('/'), + label: folderName, + icon: 'lucide-folder-open', + depth: depth, + expanded: true, + meta: '', + onToggle: function() {} + }); + container.appendChild(row); + const childGroup = document.createElement('div'); + childGroup.className = 'document-tree-group document-tree-group--folder'; + childGroup.setAttribute('role', 'group'); + appendVirtualNode(child, childGroup, depth + 1, pathParts.concat(folderName)); + container.appendChild(childGroup); + }); + node.documents.sort(function(left, right) { return (left.title || '').localeCompare(right.title || ''); }).forEach(function(tab) { + if (appendDocumentTreeItem(container, tab, depth, getLinkedDocumentMeta(tab))) renderedDocuments++; + }); + }; + appendVirtualNode(rootNode, rootGroup, 1, []); + tree.appendChild(rootGroup); + }); + return renderedDocuments; + } + function renderWorkspaceTree(tree) { let renderedDocuments = 0; const temporaryTabs = tabs.filter(function(tab) { @@ -6744,11 +6944,13 @@ document.addEventListener("DOMContentLoaded", async function () { }); } + renderedDocuments += renderLinkedLocationsTree(tree); + documentOrganization.workspaces.forEach(function(workspace) { const isSecretWorkspace = workspace.id === SECRET_WORKSPACE_ID; const secretLocked = isSecretWorkspace && !isSecretWorkspaceUnlocked(); const workspaceDocuments = tabs.filter(function(tab) { - return !isTemporaryDocument(tab) && tab.workspaceId === workspace.id; + return !isTemporaryDocument(tab) && !tab.sourcePath && tab.workspaceId === workspace.id; }); const folders = documentOrganization.folders.filter(function(folder) { return folder.workspaceId === workspace.id; }); const matchingDocuments = workspaceDocuments.filter(documentMatchesSidebarSearch); @@ -6956,6 +7158,8 @@ document.addEventListener("DOMContentLoaded", async function () { : (preferredWorkspace ? preferredWorkspace.name : 'Workspace'); const newDocumentButton = document.getElementById('sidebar-new-document'); const newFolderButton = document.getElementById('sidebar-new-folder'); + const linkFilesButton = document.getElementById('sidebar-link-files'); + const linkFolderButton = document.getElementById('sidebar-link-folder'); const collapseAllButton = document.getElementById('document-sidebar-collapse-all'); if (newDocumentButton) { newDocumentButton.title = 'New Markdown file in ' + preferredLocationLabel; @@ -7225,6 +7429,8 @@ document.addEventListener("DOMContentLoaded", async function () { const collapseAllButton = document.getElementById('document-sidebar-collapse-all'); const newDocumentButton = document.getElementById('sidebar-new-document'); const newFolderButton = document.getElementById('sidebar-new-folder'); + const linkFilesButton = document.getElementById('sidebar-link-files'); + const linkFolderButton = document.getElementById('sidebar-link-folder'); const trashButton = document.getElementById('sidebar-trash-button'); const search = document.getElementById('document-sidebar-search'); const clearSearch = document.getElementById('document-sidebar-search-clear'); @@ -7236,6 +7442,8 @@ document.addEventListener("DOMContentLoaded", async function () { if (closeButton) closeButton.addEventListener('click', toggleDocumentSidebarCollapsed); if (backdrop) backdrop.addEventListener('click', closeDocumentSidebarOnMobile); if (collapseAllButton) collapseAllButton.addEventListener('click', toggleDocumentTreeExpansion); + if (linkFilesButton) linkFilesButton.addEventListener('click', function() { void nativeLinkMarkdownFiles(); }); + if (linkFolderButton) linkFolderButton.addEventListener('click', function() { void nativeLinkMarkdownFolder(); }); if (newDocumentButton) newDocumentButton.addEventListener('click', function() { const location = getPreferredDocumentLocation(); newTab('', null, location); @@ -10862,9 +11070,16 @@ document.addEventListener("DOMContentLoaded", async function () { if (!saveStatus || !saveStatusIcon || !saveStatusText) return; const requestedState = state === 'saving' || state === 'error' ? state : 'saved'; const nextState = isPrivateStorageMode() && requestedState !== 'error' ? 'private' : requestedState; + let activeDocument = null; + try { + activeDocument = tabs.find(function(tab) { return tab.id === activeTabId; }); + } catch (_) { + // Startup calls this before the workspace tab state is initialized. + } + const linkedSourcePath = activeDocument && activeDocument.sourcePath ? activeDocument.sourcePath : ''; const presentation = { saving: { icon: 'lucide lucide-refresh-cw', text: 'Saving...' }, - saved: { icon: 'lucide lucide-check', text: 'All changes saved' }, + saved: { icon: 'lucide lucide-check', text: linkedSourcePath ? 'Draft saved · Ctrl+S updates original' : 'All changes saved' }, private: { icon: 'lucide lucide-hat-glasses', text: 'Private mode is on' }, error: { icon: 'lucide lucide-circle-alert', text: 'Changes not saved' } }[nextState]; @@ -10873,6 +11088,8 @@ document.addEventListener("DOMContentLoaded", async function () { saveStatus.dataset.state = nextState; if (nextState === 'private') { saveStatus.title = 'Private mode is on. Session changes are not saved.'; + } else if (nextState === 'saved' && linkedSourcePath) { + saveStatus.title = 'Workspace recovery copy saved. Press Ctrl+S to update ' + linkedSourcePath; } else { saveStatus.removeAttribute('title'); } @@ -11093,6 +11310,450 @@ document.addEventListener("DOMContentLoaded", async function () { return tab; } + function normalizeLinkedSourcePath(sourcePath) { + const value = String(sourcePath || '').trim(); + if (!value) return ''; + const normalized = value.replace(/\//g, '\\').replace(/\\+$/g, ''); + return /^[a-z]:\\/i.test(normalized) ? normalized.toLocaleLowerCase('en-US') : normalized; + } + + function findTabBySourcePath(sourcePath) { + const pathKey = normalizeLinkedSourcePath(sourcePath); + if (!pathKey) return null; + return tabs.find(function(tab) { + return normalizeLinkedSourcePath(tab && tab.sourcePath) === pathKey; + }) || null; + } + + async function hashLinkedSourceContent(content) { + if (!window.crypto || !window.crypto.subtle || typeof TextEncoder === 'undefined') return ''; + const bytes = new TextEncoder().encode(String(content == null ? '' : content)); + const digest = new Uint8Array(await window.crypto.subtle.digest('SHA-256', bytes)); + return Array.from(digest).map(function(byte) { return byte.toString(16).padStart(2, '0'); }).join(''); + } + + async function openLinkedMarkdownDocument(content, title, sourcePath, options) { + const settings = options || {}; + const normalizedPath = String(sourcePath || '').trim(); + const incomingContent = String(content == null ? '' : content); + const incomingHash = await hashLinkedSourceContent(incomingContent); + let tab = findTabBySourcePath(normalizedPath); + let contentRefreshed = false; + + // Adopt one matching pre-link document created by the earlier launcher build. + if (!tab && normalizedPath) { + tab = tabs.find(function(candidate) { + return !candidate.sourcePath && candidate.title === title && candidate.content === incomingContent; + }) || null; + } + + if (tab) { + await ensureTabContent(tab); + const draftHash = await hashLinkedSourceContent(tab.content); + const hasLocalDraft = Boolean(tab.sourceContentHash && draftHash && draftHash !== tab.sourceContentHash); + if (!hasLocalDraft) { + tab.content = incomingContent; + tab.contentLoaded = true; + tab._persistedContent = undefined; + contentRefreshed = true; + } else if (incomingHash && incomingHash !== tab.sourceContentHash) { + showAppToast('The file changed outside Markdown Viewer. Your workspace draft was kept; press Ctrl+S only if you want to overwrite the disk file.', { + tone: 'info', + title: 'External change detected' + }); + } + tab.sourcePath = normalizedPath || tab.sourcePath; + if (!hasLocalDraft) tab.sourceContentHash = incomingHash; + tab.sourceMissing = false; + tab.sourceConflict = hasLocalDraft && Boolean(incomingHash && incomingHash !== tab.sourceContentHash); + tab.title = title || tab.title; + if (settings.open !== false) tab.isOpen = true; + tab.lastOpenedAt = Date.now(); + } else { + tab = createTab(incomingContent, title || 'Untitled'); + tab.sourcePath = normalizedPath; + tab.sourceContentHash = incomingHash; + tab.sourceMissing = false; + tab.sourceConflict = false; + tab.isOpen = settings.open === false ? false : true; + tabs.push(tab); + } + + tab.sourceKind = 'linked'; + if (settings.linkedRootId) tab.linkedRootId = String(settings.linkedRootId); + if (settings.sourceRelativePath) tab.sourceRelativePath = String(settings.sourceRelativePath).replace(/\\/g, '/').replace(/^\/+/, ''); + + if (settings.open !== false) selectedDocumentId = tab.id; + saveTabsToStorage(tabs, [tab.id]); + if (settings.open === false) { + if (tab.id === activeTabId && contentRefreshed) { + markdownEditor.value = tab.content; + renderMarkdown(); + } + renderTabBar(tabs, activeTabId); + renderDocumentSidebar(); + } else if (settings.activate === false) { + activeTabId = tab.id; + saveActiveTabId(tab.id); + } else { + if (tab.id === activeTabId) { + if (contentRefreshed) { + markdownEditor.value = tab.content; + renderMarkdown(); + } + renderTabBar(tabs, activeTabId); + renderDocumentSidebar(); + } else { + await switchTab(tab.id); + } + showAppToast('Linked to ' + tab.sourcePath + '. Press Ctrl+S to save changes to the original file.', { + tone: 'info', + title: 'Linked Markdown file' + }); + } + return tab; + } + + function getNativePathName(filePath) { + return String(filePath || '').replace(/[\\/]+$/, '').split(/[\\/]/).pop() || ''; + } + + function getNativeParentPath(filePath) { + const value = String(filePath || '').replace(/[\\/]+$/, ''); + const separator = Math.max(value.lastIndexOf('\\'), value.lastIndexOf('/')); + return separator > 2 ? value.slice(0, separator) : value.slice(0, separator + 1); + } + + function getLinkedLocation(locationId) { + return (documentOrganization.linkedLocations || []).find(function(location) { return location.id === locationId; }) || null; + } + + function normalizeLinkedRelativePath(value) { + return String(value || '').replace(/\\/g, '/').replace(/^\/+/, ''); + } + + async function collectMarkdownFilesFromFolder(rootPath) { + const files = []; + const ignoredDirectories = new Set(['.git', 'node_modules']); + async function visit(folderPath, relativePrefix) { + if (files.length >= 5000) throw new Error('This folder contains more than 5,000 Markdown files. Link a smaller folder instead.'); + const entries = await Neutralino.filesystem.readDirectory(folderPath); + for (const entry of entries) { + if (!entry || !entry.entry || entry.entry === '.' || entry.entry === '..') continue; + const childPath = await Neutralino.filesystem.getJoinedPath(folderPath, entry.entry); + const relativePath = normalizeLinkedRelativePath(relativePrefix ? relativePrefix + '/' + entry.entry : entry.entry); + if (entry.type === 'DIRECTORY') { + if (!ignoredDirectories.has(String(entry.entry).toLowerCase())) await visit(childPath, relativePath); + } else if (entry.type === 'FILE' && /\.(md|markdown)$/i.test(entry.entry)) { + files.push({ path: childPath, relativePath: relativePath }); + } + } + } + await visit(rootPath, ''); + return files; + } + + async function reloadLinkedDocument(tabId, options) { + const tab = tabs.find(function(item) { return item.id === tabId; }); + if (!tab || !tab.sourcePath || typeof Neutralino === 'undefined') return false; + const settings = options || {}; + let diskContent; + try { + diskContent = await Neutralino.filesystem.readFile(tab.sourcePath); + } catch (_) { + tab.sourceMissing = true; + tab.sourceConflict = false; + saveTabsToStorage(tabs, [tab.id]); + renderDocumentSidebar(); + return false; + } + await ensureTabContent(tab); + const diskHash = await hashLinkedSourceContent(diskContent); + const draftHash = await hashLinkedSourceContent(tab.content); + const hasLocalDraft = Boolean(tab.sourceContentHash && draftHash && draftHash !== tab.sourceContentHash); + const hasExternalChange = Boolean(tab.sourceContentHash && diskHash && diskHash !== tab.sourceContentHash); + if (hasLocalDraft && hasExternalChange && !settings.force) { + tab.sourceConflict = true; + tab.sourceMissing = false; + saveTabsToStorage(tabs, [tab.id]); + renderDocumentSidebar(); + showAppToast('The original changed on disk while a local draft exists. The draft was kept.', { tone: 'info', title: 'Linked file conflict' }); + return false; + } + if (hasLocalDraft && settings.force) { + const replaceDraft = window.confirm('Discard the current workspace draft and reload this linked file from disk?\n\n' + tab.sourcePath); + if (!replaceDraft) return false; + } + if (!hasExternalChange && !tab.sourceMissing && !settings.force) return true; + tab.content = String(diskContent == null ? '' : diskContent); + tab.contentLoaded = true; + tab._persistedContent = undefined; + tab.sourceContentHash = diskHash; + tab.sourceMissing = false; + tab.sourceConflict = false; + tab.lastEditedAt = Date.now(); + saveTabsToStorage(tabs, [tab.id]); + if (tab.id === activeTabId) { + markdownEditor.value = tab.content; + renderMarkdown(); + } + renderTabBar(tabs, activeTabId); + renderDocumentSidebar(); + return true; + } + + async function reconcileLinkedLocation(locationId, options) { + const location = getLinkedLocation(locationId); + if (!location) return 0; + const settings = options || {}; + let files; + try { + files = await collectMarkdownFilesFromFolder(location.path); + location.unavailable = false; + } catch (error) { + location.unavailable = true; + saveDocumentOrganization(); + renderDocumentSidebar(); + if (settings.notify) showAppToast('The linked folder is unavailable: ' + location.path, { tone: 'error', title: 'Folder scan failed' }); + return 0; + } + const excluded = new Set((location.excludedPaths || []).map(normalizeLinkedRelativePath)); + const discoveredPaths = new Set(); + let added = 0; + for (const file of files) { + if (excluded.has(file.relativePath)) continue; + discoveredPaths.add(normalizeLinkedSourcePath(file.path)); + let existing = findTabBySourcePath(file.path); + try { + const content = await Neutralino.filesystem.readFile(file.path); + await openLinkedMarkdownDocument(content, getNativePathName(file.path).replace(/\.(md|markdown)$/i, ''), file.path, { + activate: false, + open: false, + linkedRootId: location.id, + sourceRelativePath: file.relativePath + }); + if (!existing) added++; + } catch (error) { + console.warn('Could not read linked Markdown file:', file.path, error); + } + } + tabs.filter(function(tab) { return tab.linkedRootId === location.id; }).forEach(function(tab) { + if (!discoveredPaths.has(normalizeLinkedSourcePath(tab.sourcePath))) tab.sourceMissing = true; + }); + location.lastScanAt = Date.now(); + saveDocumentOrganization(); + saveTabsToStorage(tabs); + renderDocumentSidebar(); + if (settings.notify) showAppToast('Linked folder scanned. ' + added + ' new Markdown file' + (added === 1 ? '' : 's') + ' found.', { tone: 'success', title: location.name }); + return added; + } + + async function addLinkedFolderPath(folderPath, options) { + const normalized = normalizeLinkedSourcePath(folderPath); + let location = (documentOrganization.linkedLocations || []).find(function(item) { + return normalizeLinkedSourcePath(item.path) === normalized; + }); + if (!location) { + location = { + id: createDocumentEntityId('linked'), + path: String(folderPath), + name: getNativePathName(folderPath) || 'Linked folder', + expanded: true, + excludedPaths: [], + createdAt: Date.now(), + lastScanAt: 0, + unavailable: false + }; + documentOrganization.linkedLocations.push(location); + saveDocumentOrganization(); + } + await reconcileLinkedLocation(location.id, { notify: !(options && options.silent) }); + await refreshLinkedSourceMonitoring(); + return location; + } + + async function addLinkedFilePath(filePath, options) { + if (!/\.(md|markdown)$/i.test(filePath)) return null; + const content = await Neutralino.filesystem.readFile(filePath); + const tab = await openLinkedMarkdownDocument(content, getNativePathName(filePath).replace(/\.(md|markdown)$/i, ''), filePath, { + activate: options && options.activate === true, + open: options && options.open === true + }); + await refreshLinkedSourceMonitoring(); + return tab; + } + + async function nativeLinkMarkdownFiles() { + try { + const paths = await Neutralino.os.showOpenDialog('Link Markdown files', { + filters: [{ name: 'Markdown files (*.md, *.markdown)', extensions: ['md', 'markdown'] }], + multiSelections: true + }); + if (!paths || !paths.length) return 0; + let count = 0; + for (const filePath of paths) { + if (await addLinkedFilePath(filePath, { open: false })) count++; + } + showAppToast(count + ' file' + (count === 1 ? '' : 's') + ' linked. Originals remain on disk.', { tone: 'success', title: 'Linked files' }); + return count; + } catch (error) { + console.warn('Could not link Markdown files:', error); + showAppToast(error.message || 'The selected files could not be linked.', { tone: 'error', title: 'Link failed' }); + return 0; + } + } + + async function nativeLinkMarkdownFolder() { + try { + const folderPath = await Neutralino.os.showFolderDialog('Link Markdown folder'); + if (folderPath) await addLinkedFolderPath(folderPath); + } catch (error) { + console.warn('Could not link Markdown folder:', error); + showAppToast(error.message || 'The selected folder could not be linked.', { tone: 'error', title: 'Link failed' }); + } + } + + async function revealLinkedSource(tab) { + if (!tab || !tab.sourcePath) return; + await Neutralino.os.open(getNativeParentPath(tab.sourcePath)); + } + + async function convertLinkedDocumentToVault(tabId) { + const tab = tabs.find(function(item) { return item.id === tabId; }); + if (!tab || !tab.sourcePath) return; + await ensureTabContent(tab); + const sourcePath = tab.sourcePath; + const location = tab.linkedRootId ? getLinkedLocation(tab.linkedRootId) : null; + if (location && tab.sourceRelativePath) { + location.excludedPaths = Array.from(new Set((location.excludedPaths || []).concat(normalizeLinkedRelativePath(tab.sourceRelativePath)))); + saveDocumentOrganization(); + } + delete tab.sourcePath; + delete tab.sourceContentHash; + delete tab.sourceKind; + delete tab.linkedRootId; + delete tab.sourceRelativePath; + delete tab.sourceMissing; + delete tab.sourceConflict; + tab.workspaceId = DEFAULT_WORKSPACE_ID; + tab.folderId = null; + tab.isOpen = true; + saveTabsToStorage(tabs, [tab.id]); + renderTabBar(tabs, activeTabId); + renderDocumentSidebar(); + await refreshLinkedSourceMonitoring(); + showAppToast('A separate Vault copy was created. The original file was left unchanged at ' + sourcePath, { tone: 'success', title: 'Converted to Workspace copy' }); + } + + async function removeLinkedDocument(tabId) { + const tab = tabs.find(function(item) { return item.id === tabId; }); + if (!tab || !tab.sourcePath) return; + const location = tab.linkedRootId ? getLinkedLocation(tab.linkedRootId) : null; + if (location && tab.sourceRelativePath) { + location.excludedPaths = Array.from(new Set((location.excludedPaths || []).concat(normalizeLinkedRelativePath(tab.sourceRelativePath)))); + saveDocumentOrganization(); + } + await deleteTab(tab.id); + await refreshLinkedSourceMonitoring(); + showAppToast('Link removed. The original file remains on disk.', { tone: 'success', title: 'Linked file closed' }); + } + + async function removeLinkedLocation(locationId) { + const location = getLinkedLocation(locationId); + if (!location) return; + const linkedTabs = tabs.filter(function(tab) { return tab.linkedRootId === locationId; }); + const confirmed = await showAppToastConfirmation( + 'Remove this linked folder and ' + linkedTabs.length + ' file link' + (linkedTabs.length === 1 ? '' : 's') + '? Original files remain on disk.', + { title: 'Remove linked folder', confirmLabel: 'Remove link', dedupeKey: 'remove-linked-folder:' + location.id } + ); + if (!confirmed) return; + documentOrganization.linkedLocations = documentOrganization.linkedLocations.filter(function(item) { return item.id !== locationId; }); + saveDocumentOrganization(); + for (const tab of linkedTabs.slice()) await deleteTab(tab.id); + await refreshLinkedSourceMonitoring(); + renderDocumentSidebar(); + showAppToast('Linked folder removed. Original files remain on disk.', { tone: 'success', title: 'Folder unlinked' }); + } + + async function stopLinkedSourceMonitoring() { + const watchers = Array.from(linkedSourceWatchers.values()); + linkedSourceWatchers.clear(); + for (const watcher of watchers) { + try { await Neutralino.filesystem.removeWatcher(watcher.id); } catch (_) {} + } + } + + async function refreshLinkedSourceMonitoring() { + if (!linkedSourceMonitoringReady || typeof Neutralino === 'undefined' || !Neutralino.filesystem.createWatcher) return; + await stopLinkedSourceMonitoring(); + const targets = []; + (documentOrganization.linkedLocations || []).forEach(function(location) { + targets.push({ key: 'folder:' + location.id, path: location.path, locationId: location.id }); + const watchedDirectories = new Set([normalizeLinkedSourcePath(location.path)]); + tabs.filter(function(tab) { return tab.linkedRootId === location.id && tab.sourcePath; }).forEach(function(tab) { + const parentPath = getNativeParentPath(tab.sourcePath); + const normalizedParent = normalizeLinkedSourcePath(parentPath); + if (watchedDirectories.has(normalizedParent)) return; + watchedDirectories.add(normalizedParent); + targets.push({ key: 'folder:' + location.id + ':' + normalizedParent, path: parentPath, locationId: location.id }); + }); + }); + const seenParents = new Set(); + tabs.filter(function(tab) { return tab.sourcePath && !tab.linkedRootId; }).forEach(function(tab) { + const parentPath = getNativeParentPath(tab.sourcePath); + const key = normalizeLinkedSourcePath(parentPath); + if (!seenParents.has(key)) { + seenParents.add(key); + targets.push({ key: 'files:' + key, path: parentPath, individual: true }); + } + }); + for (const target of targets) { + try { + const watcherId = await Neutralino.filesystem.createWatcher(target.path); + linkedSourceWatchers.set(target.key, Object.assign({ id: watcherId }, target)); + } catch (error) { + console.warn('Could not watch linked location:', target.path, error); + } + } + } + + function scheduleLinkedWatcherRefresh(watcher) { + if (!watcher) return; + const key = watcher.locationId ? 'location:' + watcher.locationId : watcher.key; + clearTimeout(linkedSourceRefreshTimers.get(key)); + linkedSourceRefreshTimers.set(key, setTimeout(async function() { + linkedSourceRefreshTimers.delete(key); + if (watcher.locationId) { + await reconcileLinkedLocation(watcher.locationId); + await refreshLinkedSourceMonitoring(); + } + else { + for (const tab of tabs.filter(function(item) { return item.sourcePath && !item.linkedRootId && normalizeLinkedSourcePath(getNativeParentPath(item.sourcePath)) === normalizeLinkedSourcePath(watcher.path); })) { + await reloadLinkedDocument(tab.id); + } + } + }, 350)); + } + + async function initializeLinkedSourceMonitoring() { + if (linkedSourceMonitoringReady || typeof Neutralino === 'undefined') return; + linkedSourceMonitoringReady = true; + if (Neutralino.events && typeof Neutralino.events.on === 'function') { + await Neutralino.events.on('watchFile', function(event) { + const watcher = Array.from(linkedSourceWatchers.values()).find(function(item) { return item.id === event.detail.id; }); + scheduleLinkedWatcherRefresh(watcher); + }); + await Neutralino.events.on('windowFocus', function() { + Array.from(linkedSourceWatchers.values()).forEach(scheduleLinkedWatcherRefresh); + }); + } + await refreshLinkedSourceMonitoring(); + } + + window.NL_START_LINKED_MONITORING = function() { + return initializeLinkedSourceMonitoring(); + }; + function isTabOpen(tab) { return Boolean(tab && tab.isOpen !== false); } @@ -11624,7 +12285,7 @@ document.addEventListener("DOMContentLoaded", async function () { fileIcon.setAttribute('aria-hidden', 'true'); } else { fileIcon = document.createElement('i'); - fileIcon.className = 'lucide ' + (splitPartner ? 'lucide-columns-2' : 'lucide-file-text') + ' tab-file-icon'; + fileIcon.className = 'lucide ' + (splitPartner ? 'lucide-columns-2' : (tab.sourcePath ? 'lucide-link-2' : 'lucide-file-text')) + ' tab-file-icon'; fileIcon.setAttribute('aria-hidden', 'true'); } @@ -12732,11 +13393,12 @@ document.addEventListener("DOMContentLoaded", async function () { // Check if Neutralino passed an initial file via command line (early load) if (window.NL_INITIAL_FILE_CONTENT) { const initialFile = window.NL_INITIAL_FILE_CONTENT; - const tab = createTab(initialFile.content, initialFile.name); - tabs.push(tab); - activeTabId = tab.id; - saveTabsToStorage(tabs); - saveActiveTabId(activeTabId); + await openLinkedMarkdownDocument( + initialFile.content, + initialFile.name, + initialFile.sourcePath, + { activate: false } + ); delete window.NL_INITIAL_FILE_CONTENT; } else if (tabs.length === 0) { const tab = createTab(sampleMarkdown, 'Welcome to Markdown'); @@ -12848,14 +13510,9 @@ document.addEventListener("DOMContentLoaded", async function () { } // Late-load callback hook for Neutralino command-line files - window.NL_IMPORT_EXTERNAL_FILE = function(content, name) { + window.NL_IMPORT_EXTERNAL_FILE = async function(content, name, sourcePath) { if (typeof tabs === 'undefined') return; - const existing = tabs.find(function(t) { return t.title === name && t.content === content; }); - if (existing) { - switchTab(existing.id); - return; - } - newTab(content, name); + await openLinkedMarkdownDocument(content, name, sourcePath, { activate: true }); }; function showPreviewSkeleton() { @@ -23769,9 +24426,57 @@ ${selector} .arrowheadPath { updateStlThemes(); }); - async function nativeSaveMarkdown() { + async function writeLinkedMarkdownSource(tab, content) { + if (!tab || !tab.sourcePath) return false; + let diskContent = null; + try { + diskContent = await Neutralino.filesystem.readFile(tab.sourcePath); + } catch (_) { + const recreate = window.confirm('The linked file no longer exists:\n\n' + tab.sourcePath + '\n\nCreate it again at this location?'); + if (!recreate) return true; + } + + if (diskContent !== null && tab.sourceContentHash) { + const diskHash = await hashLinkedSourceContent(diskContent); + if (diskHash && diskHash !== tab.sourceContentHash) { + const overwrite = window.confirm( + 'This file changed outside Markdown Viewer after it was opened:\n\n' + + tab.sourcePath + + '\n\nOverwrite the external changes with the current editor content?' + ); + if (!overwrite) return true; + } + } + + await Neutralino.filesystem.writeFile(tab.sourcePath, content); + const verified = await Neutralino.filesystem.readFile(tab.sourcePath); + if (verified !== content) throw new Error('The linked file write could not be verified.'); + tab.sourceContentHash = await hashLinkedSourceContent(content); + tab.sourceMissing = false; + tab.sourceConflict = false; + tab.content = content; + tab.contentLoaded = true; + tab.lastEditedAt = Date.now(); + saveTabsToStorage(tabs, [tab.id]); + renderTabBar(tabs, activeTabId); + renderDocumentSidebar(); + showAppToast('Saved to ' + tab.sourcePath, { + tone: 'success', + title: 'Original file updated' + }); + return true; + } + + async function nativeSaveMarkdown(options) { try { + const settings = options || {}; const content = markdownEditor.value; + saveCurrentTabState(); + const activeTab = tabs.find(function(t) { return t.id === activeTabId; }); + if (settings.useLinkedSource && activeTab && activeTab.sourcePath) { + await writeLinkedMarkdownSource(activeTab, content); + return; + } const result = await Neutralino.os.showSaveDialog("Save Markdown File", { filters: [ { name: "Markdown files (*.md)", extensions: ["md", "markdown"] }, @@ -23781,12 +24486,27 @@ ${selector} .arrowheadPath { if (result) { await Neutralino.filesystem.writeFile(result, content); const fileName = result.split(/[/\\]/).pop().replace(/\.(md|markdown)$/i, ""); - const activeTab = tabs.find(function(t) { return t.id === activeTabId; }); if (activeTab) { activeTab.title = fileName; activeTab.content = content; + if (settings.useLinkedSource) { + activeTab.sourcePath = result; + activeTab.sourceContentHash = await hashLinkedSourceContent(content); + activeTab.sourceKind = 'linked'; + activeTab.sourceMissing = false; + activeTab.sourceConflict = false; + delete activeTab.linkedRootId; + delete activeTab.sourceRelativePath; + } saveTabsToStorage(tabs, [activeTab.id]); renderTabBar(tabs, activeTabId); + renderDocumentSidebar(); + if (settings.useLinkedSource) { + showAppToast('Linked to ' + result + '. Future Ctrl+S saves will update this file.', { + tone: 'success', + title: 'Linked Markdown file' + }); + } } } } catch (e) { @@ -23859,6 +24579,20 @@ ${selector} .arrowheadPath { }); } + if (linkFromFileButton) { + linkFromFileButton.addEventListener('click', function(event) { + event.preventDefault(); + void nativeLinkMarkdownFiles(); + }); + } + + if (linkFromFolderButton) { + linkFromFolderButton.addEventListener('click', function(event) { + event.preventDefault(); + void nativeLinkMarkdownFolder(); + }); + } + if (importFromGithubButton) { importFromGithubButton.addEventListener("click", function (e) { e.preventDefault(); @@ -28909,7 +29643,8 @@ ${selector} .arrowheadPath { if (isCmdOrCtrl && !e.shiftKey && key === 's') { e.preventDefault(); - exportMd.click(); + if (typeof Neutralino !== 'undefined') nativeSaveMarkdown({ useLinkedSource: true }); + else exportMd.click(); } if (isCmdOrCtrl && key === 'c') { const activeEl = document.activeElement; diff --git a/desktop-app/resources/js/workspace-storage.js b/desktop-app/resources/js/workspace-storage.js index df3eb067..e6b40ea7 100644 --- a/desktop-app/resources/js/workspace-storage.js +++ b/desktop-app/resources/js/workspace-storage.js @@ -278,6 +278,7 @@ mergeCollection('workspaces'); mergeCollection('folders'); + mergeCollection('linkedLocations'); merged.ui = mergeObjectChanges(base.ui || {}, local.ui || {}, remote.ui || {}); return merged; } diff --git a/desktop-app/resources/styles.css b/desktop-app/resources/styles.css index 9e1692fb..094b6185 100644 --- a/desktop-app/resources/styles.css +++ b/desktop-app/resources/styles.css @@ -752,6 +752,20 @@ body { font-size: var(--ui-font-xs); } +.document-tree-row.is-linked-document .document-tree-main > .lucide-link-2 { + color: var(--accent-color); +} + +.document-tree-row.is-source-missing .document-tree-meta, +.document-tree-row.is-source-missing .document-tree-main > i { + color: var(--color-danger-fg); +} + +.document-tree-row.is-source-conflict .document-tree-meta { + color: #bf8700; + font-weight: 600; +} + .document-tree-main > .document-favorite-indicator { flex: 0 0 auto; color: var(--favorite-color); diff --git a/index.html b/index.html index bd610001..348f02f0 100644 --- a/index.html +++ b/index.html @@ -169,7 +169,9 @@

Markdown Viewer - Online Markd @@ -443,6 +445,12 @@

Explorer

+ + diff --git a/script.js b/script.js index 133e6911..670dabda 100644 --- a/script.js +++ b/script.js @@ -361,6 +361,8 @@ document.addEventListener("DOMContentLoaded", async function () { const themeSwitchIcon = document.getElementById("theme-switch-icon"); const directionToggle = document.getElementById("direction-toggle"); const importFromFileButton = document.getElementById("import-from-file"); + const linkFromFileButton = document.getElementById("link-from-file"); + const linkFromFolderButton = document.getElementById("link-from-folder"); const importFromGithubButton = document.getElementById("import-from-github"); const fileInput = document.getElementById("file-input"); const exportDropdown = document.getElementById("exportDropdown"); @@ -4207,7 +4209,7 @@ document.addEventListener("DOMContentLoaded", async function () { const UNTITLED_COUNTER_KEY = 'markdownViewerUntitledCounter'; const DOCUMENT_ORGANIZATION_KEY = 'markdownViewerDocumentOrganization'; const SECRET_WORKSPACE_STORAGE_KEY = 'markdownViewerSecretWorkspace'; - const DOCUMENT_ORGANIZATION_VERSION = 3; + const DOCUMENT_ORGANIZATION_VERSION = 4; const SECRET_WORKSPACE_VERSION = 2; const SECRET_KDF_ITERATIONS = 250000; const DEFAULT_WORKSPACE_ID = 'workspace_default'; @@ -4235,6 +4237,9 @@ document.addEventListener("DOMContentLoaded", async function () { let documentTreeDragExpandRow = null; let documentTreeAutoScrollFrame = null; let documentTreeAutoScrollSpeed = 0; + const linkedSourceWatchers = new Map(); + const linkedSourceRefreshTimers = new Map(); + let linkedSourceMonitoringReady = false; let secretWorkspaceKey = null; let secretWorkspaceSalt = null; let secretWorkspaceIterations = SECRET_KDF_ITERATIONS; @@ -4277,7 +4282,7 @@ document.addEventListener("DOMContentLoaded", async function () { workspaces: [ { id: DEFAULT_WORKSPACE_ID, - name: 'Workspace', + name: isNeutralinoRuntimeAvailable() ? 'Workspace (Vault)' : 'Workspace', expanded: true, createdAt: 0 }, @@ -4289,6 +4294,7 @@ document.addEventListener("DOMContentLoaded", async function () { } ], folders: [], + linkedLocations: [], ui: { filter: 'all', collapsed: false, @@ -4349,6 +4355,27 @@ document.addEventListener("DOMContentLoaded", async function () { } }); + const seenLinkedLocationIds = new Set(); + const linkedLocations = (Array.isArray(source.linkedLocations) ? source.linkedLocations : []).map(function(location) { + if (!location || typeof location !== 'object') return null; + const id = String(location.id || '').slice(0, 120); + const path = String(location.path || '').trim(); + if (!id || !path || seenLinkedLocationIds.has(id)) return null; + seenLinkedLocationIds.add(id); + return { + id: id, + path: path, + name: String(location.name || '').trim().slice(0, 160) || path.split(/[/\\]/).filter(Boolean).pop() || 'Linked folder', + expanded: location.expanded !== false, + excludedPaths: Array.from(new Set((Array.isArray(location.excludedPaths) ? location.excludedPaths : []) + .map(function(item) { return String(item || '').replace(/\\/g, '/').replace(/^\/+/, ''); }) + .filter(Boolean))), + createdAt: Number.isFinite(Number(location.createdAt)) ? Number(location.createdAt) : Date.now(), + lastScanAt: Number.isFinite(Number(location.lastScanAt)) ? Number(location.lastScanAt) : 0, + unavailable: location.unavailable === true + }; + }).filter(Boolean); + const ui = source.ui && typeof source.ui === 'object' ? source.ui : {}; const allowedFilters = new Set(['all', 'recent', 'favorites']); const width = Number(ui.width); @@ -4360,6 +4387,7 @@ document.addEventListener("DOMContentLoaded", async function () { version: DOCUMENT_ORGANIZATION_VERSION, workspaces: workspaces, folders: folders, + linkedLocations: linkedLocations, ui: { filter: allowedFilters.has(ui.filter) ? ui.filter : 'all', collapsed: ui.collapsed === true, @@ -4417,6 +4445,18 @@ document.addEventListener("DOMContentLoaded", async function () { folders: documentOrganization.folders.filter(function(folder) { return folder.workspaceId !== SECRET_WORKSPACE_ID; }), + linkedLocations: (documentOrganization.linkedLocations || []).map(function(location) { + return { + id: location.id, + path: location.path, + name: location.name, + expanded: location.expanded !== false, + excludedPaths: Array.isArray(location.excludedPaths) ? location.excludedPaths.slice() : [], + createdAt: location.createdAt, + lastScanAt: location.lastScanAt || 0, + unavailable: location.unavailable === true + }; + }), ui: Object.assign({}, documentOrganization.ui, { lastWorkspaceId: documentOrganization.ui.lastWorkspaceId === SECRET_WORKSPACE_ID && !secretWorkspaceKey ? DEFAULT_WORKSPACE_ID @@ -5849,6 +5889,18 @@ document.addEventListener("DOMContentLoaded", async function () { } function getDocumentMenuActions(tab) { + if (tab && tab.sourcePath && !isTemporaryDocument(tab)) { + const linkedActions = [ + { id: 'open', icon: 'lucide-file-text', label: 'Open', run: function() { openSidebarDocument(tab.id); } }, + { id: 'reload-source', icon: 'lucide-refresh-cw', label: 'Reload from disk', run: function() { void reloadLinkedDocument(tab.id, { force: true }); } }, + { id: 'reveal-source', icon: 'lucide-folder-open', label: 'Open containing folder', run: function() { void revealLinkedSource(tab); } }, + { id: 'copy-source-path', icon: 'lucide-copy', label: 'Copy source path', run: function() { void copyTextToClipboard(tab.sourcePath); } }, + { separator: true }, + { id: 'convert-vault', icon: 'lucide-files', label: 'Convert to Workspace copy', run: function() { void convertLinkedDocumentToVault(tab.id); } }, + { id: 'remove-link', icon: 'lucide-x', label: 'Remove link from Workspace', run: function() { void removeLinkedDocument(tab.id); } } + ]; + return linkedActions; + } const actions = [ { id: 'open', icon: 'lucide-file-text', label: 'Open', run: function() { openSidebarDocument(tab.id); } }, { id: 'rename', icon: 'lucide-square-pen', label: 'Rename', run: function() { renameTab(tab.id); } @@ -5929,6 +5981,10 @@ document.addEventListener("DOMContentLoaded", async function () { const workspace = getWorkspaceById(id); return workspace ? getWorkspaceMenuActions(workspace) : []; } + if (type === 'linked-location') { + const location = (documentOrganization.linkedLocations || []).find(function(item) { return item.id === id; }); + return location ? getLinkedLocationMenuActions(location) : []; + } return []; } @@ -6517,7 +6573,7 @@ document.addEventListener("DOMContentLoaded", async function () { dragOverlay.setAttribute('aria-hidden', 'true'); } const importFiles = function() { - handleIncomingDroppedFiles(files, { location: location }); + handleIncomingDroppedFiles(files, { location: location, source: 'drop' }); }; if (location.workspaceId === SECRET_WORKSPACE_ID && !isSecretWorkspaceUnlocked()) { withUnlockedSecretWorkspace(importFiles); @@ -6672,8 +6728,8 @@ document.addEventListener("DOMContentLoaded", async function () { id: tab.id, documentId: tab.id, label: tab.title || 'Untitled', - ariaLabel: (tab.id === activeTabId ? 'Active document, ' : 'Document, ') + (tab.title || 'Untitled') + (tab.favorite ? ', favorite' : ''), - icon: 'lucide-file-text', + ariaLabel: (tab.id === activeTabId ? 'Active document, ' : 'Document, ') + (tab.title || 'Untitled') + (tab.sourcePath ? ', linked file' : '') + (tab.favorite ? ', favorite' : ''), + icon: tab.sourcePath ? 'lucide-link-2' : 'lucide-file-text', depth: depth, favorite: tab.favorite === true, temporary: isTemporaryDocument(tab), @@ -6683,6 +6739,12 @@ document.addEventListener("DOMContentLoaded", async function () { openSidebarDocument(tab.id); } }); + if (tab.sourcePath) { + row.classList.add('is-linked-document'); + row.title = tab.sourcePath; + } + if (tab.sourceMissing) row.classList.add('is-source-missing'); + if (tab.sourceConflict) row.classList.add('is-source-conflict'); container.appendChild(row); if (consumesRenderBudget) { documentSidebarRenderBudget.remaining = Math.max(0, documentSidebarRenderBudget.remaining - 1); @@ -6691,6 +6753,7 @@ document.addEventListener("DOMContentLoaded", async function () { } function getDocumentLocationLabel(tab) { + if (tab && tab.sourcePath) return 'Linked · ' + tab.sourcePath; const workspace = getWorkspaceById(tab.workspaceId); const folder = getFolderById(tab.folderId); if (folder && workspace) return workspace.name + ' / ' + getFolderPath(folder.id); @@ -6699,10 +6762,17 @@ document.addEventListener("DOMContentLoaded", async function () { function getDocumentTabHoverTitle(tab) { const title = tab && tab.title ? tab.title : 'Untitled'; + if (tab && tab.sourcePath) return title + ' — Linked file: ' + tab.sourcePath; const folder = tab ? getFolderById(tab.folderId) : null; return folder ? getFolderPath(folder.id) + ' / ' + title : title; } + if (isNeutralinoRuntimeAvailable()) { + document.querySelectorAll('.desktop-linked-command').forEach(function(element) { + element.hidden = false; + }); + } + function documentMatchesSidebarSearch(tab) { if (!documentSidebarSearch) return true; const haystack = ((tab.title || '') + ' ' + getDocumentLocationLabel(tab)).toLocaleLowerCase(); @@ -6727,6 +6797,136 @@ document.addEventListener("DOMContentLoaded", async function () { return renderedDocuments; } + function getLinkedDocumentMeta(tab) { + if (tab.sourceMissing) return 'Missing source'; + if (tab.sourceConflict) return 'Conflict'; + return 'Linked · recovery in Vault'; + } + + function getLinkedLocationMenuActions(location) { + return [ + { id: 'rescan', icon: 'lucide-refresh-cw', label: 'Rescan folder', run: function() { void reconcileLinkedLocation(location.id, { notify: true }); } }, + { id: 'reveal', icon: 'lucide-folder-open', label: 'Open folder', run: function() { void Neutralino.os.open(location.path); } }, + { id: 'unlink', icon: 'lucide-x', label: 'Remove linked folder', run: function() { void removeLinkedLocation(location.id); } } + ]; + } + + function renderLinkedLocationsTree(tree) { + if (typeof Neutralino === 'undefined') return 0; + const linkedTabs = tabs.filter(function(tab) { + return !isTemporaryDocument(tab) && Boolean(tab.sourcePath); + }); + const locations = Array.isArray(documentOrganization.linkedLocations) ? documentOrganization.linkedLocations : []; + if (!linkedTabs.length && !locations.length) return 0; + + let renderedDocuments = 0; + const sectionLabel = document.createElement('p'); + sectionLabel.className = 'document-tree-section-label'; + sectionLabel.textContent = 'Linked Locations'; + tree.appendChild(sectionLabel); + + const individualTabs = linkedTabs.filter(function(tab) { return !tab.linkedRootId; }) + .filter(documentMatchesSidebarSearch) + .sort(function(left, right) { return (left.title || '').localeCompare(right.title || ''); }); + if (individualTabs.length) { + const individualRow = createDocumentTreeRow({ + type: 'linked-location', + id: 'linked-files', + label: 'Linked files', + ariaLabel: 'Linked files, original files remain on disk', + icon: 'lucide-link-2', + depth: 0, + expanded: true, + meta: String(individualTabs.length), + onToggle: function() {} + }); + tree.appendChild(individualRow); + const group = document.createElement('div'); + group.className = 'document-tree-group document-tree-group--workspace'; + group.setAttribute('role', 'group'); + individualTabs.forEach(function(tab) { + if (appendDocumentTreeItem(group, tab, 1, getLinkedDocumentMeta(tab))) renderedDocuments++; + }); + tree.appendChild(group); + } + + locations.slice().sort(function(left, right) { + return (left.name || '').localeCompare(right.name || ''); + }).forEach(function(location) { + const locationTabs = linkedTabs.filter(function(tab) { return tab.linkedRootId === location.id; }); + const matchingTabs = locationTabs.filter(documentMatchesSidebarSearch); + const locationMatches = !documentSidebarSearch || (location.name + ' ' + location.path).toLocaleLowerCase().includes(documentSidebarSearch.toLocaleLowerCase()); + if (documentSidebarSearch && !locationMatches && !matchingTabs.length) return; + const expanded = documentSidebarSearch ? true : location.expanded !== false; + const rootRow = createDocumentTreeRow({ + type: 'linked-location', + id: location.id, + label: location.name, + ariaLabel: 'Linked folder, ' + location.path + (location.unavailable ? ', unavailable' : ''), + icon: location.unavailable ? 'lucide-triangle-alert' : (expanded ? 'lucide-folder-open' : 'lucide-folder'), + depth: 0, + expanded: expanded, + meta: location.unavailable ? 'Unavailable' : String(locationTabs.length), + menuActions: getLinkedLocationMenuActions(location), + onToggle: function() { + location.expanded = !expanded; + saveDocumentOrganization(); + renderDocumentSidebar(); + }, + onActivate: function() { + location.expanded = true; + saveDocumentOrganization(); + renderDocumentSidebar(); + } + }); + tree.appendChild(rootRow); + + const rootGroup = document.createElement('div'); + rootGroup.className = 'document-tree-group document-tree-group--workspace'; + rootGroup.setAttribute('role', 'group'); + rootGroup.hidden = !expanded; + const rootNode = { folders: new Map(), documents: [] }; + matchingTabs.forEach(function(tab) { + const parts = String(tab.sourceRelativePath || '').replace(/\\/g, '/').split('/').filter(Boolean); + if (parts.length) parts.pop(); + let node = rootNode; + parts.forEach(function(part) { + if (!node.folders.has(part)) node.folders.set(part, { folders: new Map(), documents: [] }); + node = node.folders.get(part); + }); + node.documents.push(tab); + }); + const appendVirtualNode = function(node, container, depth, pathParts) { + Array.from(node.folders.entries()).sort(function(left, right) { return left[0].localeCompare(right[0]); }).forEach(function(entry) { + const folderName = entry[0]; + const child = entry[1]; + const row = createDocumentTreeRow({ + type: 'linked-virtual-folder', + id: location.id + ':' + pathParts.concat(folderName).join('/'), + label: folderName, + icon: 'lucide-folder-open', + depth: depth, + expanded: true, + meta: '', + onToggle: function() {} + }); + container.appendChild(row); + const childGroup = document.createElement('div'); + childGroup.className = 'document-tree-group document-tree-group--folder'; + childGroup.setAttribute('role', 'group'); + appendVirtualNode(child, childGroup, depth + 1, pathParts.concat(folderName)); + container.appendChild(childGroup); + }); + node.documents.sort(function(left, right) { return (left.title || '').localeCompare(right.title || ''); }).forEach(function(tab) { + if (appendDocumentTreeItem(container, tab, depth, getLinkedDocumentMeta(tab))) renderedDocuments++; + }); + }; + appendVirtualNode(rootNode, rootGroup, 1, []); + tree.appendChild(rootGroup); + }); + return renderedDocuments; + } + function renderWorkspaceTree(tree) { let renderedDocuments = 0; const temporaryTabs = tabs.filter(function(tab) { @@ -6744,11 +6944,13 @@ document.addEventListener("DOMContentLoaded", async function () { }); } + renderedDocuments += renderLinkedLocationsTree(tree); + documentOrganization.workspaces.forEach(function(workspace) { const isSecretWorkspace = workspace.id === SECRET_WORKSPACE_ID; const secretLocked = isSecretWorkspace && !isSecretWorkspaceUnlocked(); const workspaceDocuments = tabs.filter(function(tab) { - return !isTemporaryDocument(tab) && tab.workspaceId === workspace.id; + return !isTemporaryDocument(tab) && !tab.sourcePath && tab.workspaceId === workspace.id; }); const folders = documentOrganization.folders.filter(function(folder) { return folder.workspaceId === workspace.id; }); const matchingDocuments = workspaceDocuments.filter(documentMatchesSidebarSearch); @@ -6956,6 +7158,8 @@ document.addEventListener("DOMContentLoaded", async function () { : (preferredWorkspace ? preferredWorkspace.name : 'Workspace'); const newDocumentButton = document.getElementById('sidebar-new-document'); const newFolderButton = document.getElementById('sidebar-new-folder'); + const linkFilesButton = document.getElementById('sidebar-link-files'); + const linkFolderButton = document.getElementById('sidebar-link-folder'); const collapseAllButton = document.getElementById('document-sidebar-collapse-all'); if (newDocumentButton) { newDocumentButton.title = 'New Markdown file in ' + preferredLocationLabel; @@ -7225,6 +7429,8 @@ document.addEventListener("DOMContentLoaded", async function () { const collapseAllButton = document.getElementById('document-sidebar-collapse-all'); const newDocumentButton = document.getElementById('sidebar-new-document'); const newFolderButton = document.getElementById('sidebar-new-folder'); + const linkFilesButton = document.getElementById('sidebar-link-files'); + const linkFolderButton = document.getElementById('sidebar-link-folder'); const trashButton = document.getElementById('sidebar-trash-button'); const search = document.getElementById('document-sidebar-search'); const clearSearch = document.getElementById('document-sidebar-search-clear'); @@ -7236,6 +7442,8 @@ document.addEventListener("DOMContentLoaded", async function () { if (closeButton) closeButton.addEventListener('click', toggleDocumentSidebarCollapsed); if (backdrop) backdrop.addEventListener('click', closeDocumentSidebarOnMobile); if (collapseAllButton) collapseAllButton.addEventListener('click', toggleDocumentTreeExpansion); + if (linkFilesButton) linkFilesButton.addEventListener('click', function() { void nativeLinkMarkdownFiles(); }); + if (linkFolderButton) linkFolderButton.addEventListener('click', function() { void nativeLinkMarkdownFolder(); }); if (newDocumentButton) newDocumentButton.addEventListener('click', function() { const location = getPreferredDocumentLocation(); newTab('', null, location); @@ -10862,9 +11070,16 @@ document.addEventListener("DOMContentLoaded", async function () { if (!saveStatus || !saveStatusIcon || !saveStatusText) return; const requestedState = state === 'saving' || state === 'error' ? state : 'saved'; const nextState = isPrivateStorageMode() && requestedState !== 'error' ? 'private' : requestedState; + let activeDocument = null; + try { + activeDocument = tabs.find(function(tab) { return tab.id === activeTabId; }); + } catch (_) { + // Startup calls this before the workspace tab state is initialized. + } + const linkedSourcePath = activeDocument && activeDocument.sourcePath ? activeDocument.sourcePath : ''; const presentation = { saving: { icon: 'lucide lucide-refresh-cw', text: 'Saving...' }, - saved: { icon: 'lucide lucide-check', text: 'All changes saved' }, + saved: { icon: 'lucide lucide-check', text: linkedSourcePath ? 'Draft saved · Ctrl+S updates original' : 'All changes saved' }, private: { icon: 'lucide lucide-hat-glasses', text: 'Private mode is on' }, error: { icon: 'lucide lucide-circle-alert', text: 'Changes not saved' } }[nextState]; @@ -10873,6 +11088,8 @@ document.addEventListener("DOMContentLoaded", async function () { saveStatus.dataset.state = nextState; if (nextState === 'private') { saveStatus.title = 'Private mode is on. Session changes are not saved.'; + } else if (nextState === 'saved' && linkedSourcePath) { + saveStatus.title = 'Workspace recovery copy saved. Press Ctrl+S to update ' + linkedSourcePath; } else { saveStatus.removeAttribute('title'); } @@ -11093,6 +11310,450 @@ document.addEventListener("DOMContentLoaded", async function () { return tab; } + function normalizeLinkedSourcePath(sourcePath) { + const value = String(sourcePath || '').trim(); + if (!value) return ''; + const normalized = value.replace(/\//g, '\\').replace(/\\+$/g, ''); + return /^[a-z]:\\/i.test(normalized) ? normalized.toLocaleLowerCase('en-US') : normalized; + } + + function findTabBySourcePath(sourcePath) { + const pathKey = normalizeLinkedSourcePath(sourcePath); + if (!pathKey) return null; + return tabs.find(function(tab) { + return normalizeLinkedSourcePath(tab && tab.sourcePath) === pathKey; + }) || null; + } + + async function hashLinkedSourceContent(content) { + if (!window.crypto || !window.crypto.subtle || typeof TextEncoder === 'undefined') return ''; + const bytes = new TextEncoder().encode(String(content == null ? '' : content)); + const digest = new Uint8Array(await window.crypto.subtle.digest('SHA-256', bytes)); + return Array.from(digest).map(function(byte) { return byte.toString(16).padStart(2, '0'); }).join(''); + } + + async function openLinkedMarkdownDocument(content, title, sourcePath, options) { + const settings = options || {}; + const normalizedPath = String(sourcePath || '').trim(); + const incomingContent = String(content == null ? '' : content); + const incomingHash = await hashLinkedSourceContent(incomingContent); + let tab = findTabBySourcePath(normalizedPath); + let contentRefreshed = false; + + // Adopt one matching pre-link document created by the earlier launcher build. + if (!tab && normalizedPath) { + tab = tabs.find(function(candidate) { + return !candidate.sourcePath && candidate.title === title && candidate.content === incomingContent; + }) || null; + } + + if (tab) { + await ensureTabContent(tab); + const draftHash = await hashLinkedSourceContent(tab.content); + const hasLocalDraft = Boolean(tab.sourceContentHash && draftHash && draftHash !== tab.sourceContentHash); + if (!hasLocalDraft) { + tab.content = incomingContent; + tab.contentLoaded = true; + tab._persistedContent = undefined; + contentRefreshed = true; + } else if (incomingHash && incomingHash !== tab.sourceContentHash) { + showAppToast('The file changed outside Markdown Viewer. Your workspace draft was kept; press Ctrl+S only if you want to overwrite the disk file.', { + tone: 'info', + title: 'External change detected' + }); + } + tab.sourcePath = normalizedPath || tab.sourcePath; + if (!hasLocalDraft) tab.sourceContentHash = incomingHash; + tab.sourceMissing = false; + tab.sourceConflict = hasLocalDraft && Boolean(incomingHash && incomingHash !== tab.sourceContentHash); + tab.title = title || tab.title; + if (settings.open !== false) tab.isOpen = true; + tab.lastOpenedAt = Date.now(); + } else { + tab = createTab(incomingContent, title || 'Untitled'); + tab.sourcePath = normalizedPath; + tab.sourceContentHash = incomingHash; + tab.sourceMissing = false; + tab.sourceConflict = false; + tab.isOpen = settings.open === false ? false : true; + tabs.push(tab); + } + + tab.sourceKind = 'linked'; + if (settings.linkedRootId) tab.linkedRootId = String(settings.linkedRootId); + if (settings.sourceRelativePath) tab.sourceRelativePath = String(settings.sourceRelativePath).replace(/\\/g, '/').replace(/^\/+/, ''); + + if (settings.open !== false) selectedDocumentId = tab.id; + saveTabsToStorage(tabs, [tab.id]); + if (settings.open === false) { + if (tab.id === activeTabId && contentRefreshed) { + markdownEditor.value = tab.content; + renderMarkdown(); + } + renderTabBar(tabs, activeTabId); + renderDocumentSidebar(); + } else if (settings.activate === false) { + activeTabId = tab.id; + saveActiveTabId(tab.id); + } else { + if (tab.id === activeTabId) { + if (contentRefreshed) { + markdownEditor.value = tab.content; + renderMarkdown(); + } + renderTabBar(tabs, activeTabId); + renderDocumentSidebar(); + } else { + await switchTab(tab.id); + } + showAppToast('Linked to ' + tab.sourcePath + '. Press Ctrl+S to save changes to the original file.', { + tone: 'info', + title: 'Linked Markdown file' + }); + } + return tab; + } + + function getNativePathName(filePath) { + return String(filePath || '').replace(/[\\/]+$/, '').split(/[\\/]/).pop() || ''; + } + + function getNativeParentPath(filePath) { + const value = String(filePath || '').replace(/[\\/]+$/, ''); + const separator = Math.max(value.lastIndexOf('\\'), value.lastIndexOf('/')); + return separator > 2 ? value.slice(0, separator) : value.slice(0, separator + 1); + } + + function getLinkedLocation(locationId) { + return (documentOrganization.linkedLocations || []).find(function(location) { return location.id === locationId; }) || null; + } + + function normalizeLinkedRelativePath(value) { + return String(value || '').replace(/\\/g, '/').replace(/^\/+/, ''); + } + + async function collectMarkdownFilesFromFolder(rootPath) { + const files = []; + const ignoredDirectories = new Set(['.git', 'node_modules']); + async function visit(folderPath, relativePrefix) { + if (files.length >= 5000) throw new Error('This folder contains more than 5,000 Markdown files. Link a smaller folder instead.'); + const entries = await Neutralino.filesystem.readDirectory(folderPath); + for (const entry of entries) { + if (!entry || !entry.entry || entry.entry === '.' || entry.entry === '..') continue; + const childPath = await Neutralino.filesystem.getJoinedPath(folderPath, entry.entry); + const relativePath = normalizeLinkedRelativePath(relativePrefix ? relativePrefix + '/' + entry.entry : entry.entry); + if (entry.type === 'DIRECTORY') { + if (!ignoredDirectories.has(String(entry.entry).toLowerCase())) await visit(childPath, relativePath); + } else if (entry.type === 'FILE' && /\.(md|markdown)$/i.test(entry.entry)) { + files.push({ path: childPath, relativePath: relativePath }); + } + } + } + await visit(rootPath, ''); + return files; + } + + async function reloadLinkedDocument(tabId, options) { + const tab = tabs.find(function(item) { return item.id === tabId; }); + if (!tab || !tab.sourcePath || typeof Neutralino === 'undefined') return false; + const settings = options || {}; + let diskContent; + try { + diskContent = await Neutralino.filesystem.readFile(tab.sourcePath); + } catch (_) { + tab.sourceMissing = true; + tab.sourceConflict = false; + saveTabsToStorage(tabs, [tab.id]); + renderDocumentSidebar(); + return false; + } + await ensureTabContent(tab); + const diskHash = await hashLinkedSourceContent(diskContent); + const draftHash = await hashLinkedSourceContent(tab.content); + const hasLocalDraft = Boolean(tab.sourceContentHash && draftHash && draftHash !== tab.sourceContentHash); + const hasExternalChange = Boolean(tab.sourceContentHash && diskHash && diskHash !== tab.sourceContentHash); + if (hasLocalDraft && hasExternalChange && !settings.force) { + tab.sourceConflict = true; + tab.sourceMissing = false; + saveTabsToStorage(tabs, [tab.id]); + renderDocumentSidebar(); + showAppToast('The original changed on disk while a local draft exists. The draft was kept.', { tone: 'info', title: 'Linked file conflict' }); + return false; + } + if (hasLocalDraft && settings.force) { + const replaceDraft = window.confirm('Discard the current workspace draft and reload this linked file from disk?\n\n' + tab.sourcePath); + if (!replaceDraft) return false; + } + if (!hasExternalChange && !tab.sourceMissing && !settings.force) return true; + tab.content = String(diskContent == null ? '' : diskContent); + tab.contentLoaded = true; + tab._persistedContent = undefined; + tab.sourceContentHash = diskHash; + tab.sourceMissing = false; + tab.sourceConflict = false; + tab.lastEditedAt = Date.now(); + saveTabsToStorage(tabs, [tab.id]); + if (tab.id === activeTabId) { + markdownEditor.value = tab.content; + renderMarkdown(); + } + renderTabBar(tabs, activeTabId); + renderDocumentSidebar(); + return true; + } + + async function reconcileLinkedLocation(locationId, options) { + const location = getLinkedLocation(locationId); + if (!location) return 0; + const settings = options || {}; + let files; + try { + files = await collectMarkdownFilesFromFolder(location.path); + location.unavailable = false; + } catch (error) { + location.unavailable = true; + saveDocumentOrganization(); + renderDocumentSidebar(); + if (settings.notify) showAppToast('The linked folder is unavailable: ' + location.path, { tone: 'error', title: 'Folder scan failed' }); + return 0; + } + const excluded = new Set((location.excludedPaths || []).map(normalizeLinkedRelativePath)); + const discoveredPaths = new Set(); + let added = 0; + for (const file of files) { + if (excluded.has(file.relativePath)) continue; + discoveredPaths.add(normalizeLinkedSourcePath(file.path)); + let existing = findTabBySourcePath(file.path); + try { + const content = await Neutralino.filesystem.readFile(file.path); + await openLinkedMarkdownDocument(content, getNativePathName(file.path).replace(/\.(md|markdown)$/i, ''), file.path, { + activate: false, + open: false, + linkedRootId: location.id, + sourceRelativePath: file.relativePath + }); + if (!existing) added++; + } catch (error) { + console.warn('Could not read linked Markdown file:', file.path, error); + } + } + tabs.filter(function(tab) { return tab.linkedRootId === location.id; }).forEach(function(tab) { + if (!discoveredPaths.has(normalizeLinkedSourcePath(tab.sourcePath))) tab.sourceMissing = true; + }); + location.lastScanAt = Date.now(); + saveDocumentOrganization(); + saveTabsToStorage(tabs); + renderDocumentSidebar(); + if (settings.notify) showAppToast('Linked folder scanned. ' + added + ' new Markdown file' + (added === 1 ? '' : 's') + ' found.', { tone: 'success', title: location.name }); + return added; + } + + async function addLinkedFolderPath(folderPath, options) { + const normalized = normalizeLinkedSourcePath(folderPath); + let location = (documentOrganization.linkedLocations || []).find(function(item) { + return normalizeLinkedSourcePath(item.path) === normalized; + }); + if (!location) { + location = { + id: createDocumentEntityId('linked'), + path: String(folderPath), + name: getNativePathName(folderPath) || 'Linked folder', + expanded: true, + excludedPaths: [], + createdAt: Date.now(), + lastScanAt: 0, + unavailable: false + }; + documentOrganization.linkedLocations.push(location); + saveDocumentOrganization(); + } + await reconcileLinkedLocation(location.id, { notify: !(options && options.silent) }); + await refreshLinkedSourceMonitoring(); + return location; + } + + async function addLinkedFilePath(filePath, options) { + if (!/\.(md|markdown)$/i.test(filePath)) return null; + const content = await Neutralino.filesystem.readFile(filePath); + const tab = await openLinkedMarkdownDocument(content, getNativePathName(filePath).replace(/\.(md|markdown)$/i, ''), filePath, { + activate: options && options.activate === true, + open: options && options.open === true + }); + await refreshLinkedSourceMonitoring(); + return tab; + } + + async function nativeLinkMarkdownFiles() { + try { + const paths = await Neutralino.os.showOpenDialog('Link Markdown files', { + filters: [{ name: 'Markdown files (*.md, *.markdown)', extensions: ['md', 'markdown'] }], + multiSelections: true + }); + if (!paths || !paths.length) return 0; + let count = 0; + for (const filePath of paths) { + if (await addLinkedFilePath(filePath, { open: false })) count++; + } + showAppToast(count + ' file' + (count === 1 ? '' : 's') + ' linked. Originals remain on disk.', { tone: 'success', title: 'Linked files' }); + return count; + } catch (error) { + console.warn('Could not link Markdown files:', error); + showAppToast(error.message || 'The selected files could not be linked.', { tone: 'error', title: 'Link failed' }); + return 0; + } + } + + async function nativeLinkMarkdownFolder() { + try { + const folderPath = await Neutralino.os.showFolderDialog('Link Markdown folder'); + if (folderPath) await addLinkedFolderPath(folderPath); + } catch (error) { + console.warn('Could not link Markdown folder:', error); + showAppToast(error.message || 'The selected folder could not be linked.', { tone: 'error', title: 'Link failed' }); + } + } + + async function revealLinkedSource(tab) { + if (!tab || !tab.sourcePath) return; + await Neutralino.os.open(getNativeParentPath(tab.sourcePath)); + } + + async function convertLinkedDocumentToVault(tabId) { + const tab = tabs.find(function(item) { return item.id === tabId; }); + if (!tab || !tab.sourcePath) return; + await ensureTabContent(tab); + const sourcePath = tab.sourcePath; + const location = tab.linkedRootId ? getLinkedLocation(tab.linkedRootId) : null; + if (location && tab.sourceRelativePath) { + location.excludedPaths = Array.from(new Set((location.excludedPaths || []).concat(normalizeLinkedRelativePath(tab.sourceRelativePath)))); + saveDocumentOrganization(); + } + delete tab.sourcePath; + delete tab.sourceContentHash; + delete tab.sourceKind; + delete tab.linkedRootId; + delete tab.sourceRelativePath; + delete tab.sourceMissing; + delete tab.sourceConflict; + tab.workspaceId = DEFAULT_WORKSPACE_ID; + tab.folderId = null; + tab.isOpen = true; + saveTabsToStorage(tabs, [tab.id]); + renderTabBar(tabs, activeTabId); + renderDocumentSidebar(); + await refreshLinkedSourceMonitoring(); + showAppToast('A separate Vault copy was created. The original file was left unchanged at ' + sourcePath, { tone: 'success', title: 'Converted to Workspace copy' }); + } + + async function removeLinkedDocument(tabId) { + const tab = tabs.find(function(item) { return item.id === tabId; }); + if (!tab || !tab.sourcePath) return; + const location = tab.linkedRootId ? getLinkedLocation(tab.linkedRootId) : null; + if (location && tab.sourceRelativePath) { + location.excludedPaths = Array.from(new Set((location.excludedPaths || []).concat(normalizeLinkedRelativePath(tab.sourceRelativePath)))); + saveDocumentOrganization(); + } + await deleteTab(tab.id); + await refreshLinkedSourceMonitoring(); + showAppToast('Link removed. The original file remains on disk.', { tone: 'success', title: 'Linked file closed' }); + } + + async function removeLinkedLocation(locationId) { + const location = getLinkedLocation(locationId); + if (!location) return; + const linkedTabs = tabs.filter(function(tab) { return tab.linkedRootId === locationId; }); + const confirmed = await showAppToastConfirmation( + 'Remove this linked folder and ' + linkedTabs.length + ' file link' + (linkedTabs.length === 1 ? '' : 's') + '? Original files remain on disk.', + { title: 'Remove linked folder', confirmLabel: 'Remove link', dedupeKey: 'remove-linked-folder:' + location.id } + ); + if (!confirmed) return; + documentOrganization.linkedLocations = documentOrganization.linkedLocations.filter(function(item) { return item.id !== locationId; }); + saveDocumentOrganization(); + for (const tab of linkedTabs.slice()) await deleteTab(tab.id); + await refreshLinkedSourceMonitoring(); + renderDocumentSidebar(); + showAppToast('Linked folder removed. Original files remain on disk.', { tone: 'success', title: 'Folder unlinked' }); + } + + async function stopLinkedSourceMonitoring() { + const watchers = Array.from(linkedSourceWatchers.values()); + linkedSourceWatchers.clear(); + for (const watcher of watchers) { + try { await Neutralino.filesystem.removeWatcher(watcher.id); } catch (_) {} + } + } + + async function refreshLinkedSourceMonitoring() { + if (!linkedSourceMonitoringReady || typeof Neutralino === 'undefined' || !Neutralino.filesystem.createWatcher) return; + await stopLinkedSourceMonitoring(); + const targets = []; + (documentOrganization.linkedLocations || []).forEach(function(location) { + targets.push({ key: 'folder:' + location.id, path: location.path, locationId: location.id }); + const watchedDirectories = new Set([normalizeLinkedSourcePath(location.path)]); + tabs.filter(function(tab) { return tab.linkedRootId === location.id && tab.sourcePath; }).forEach(function(tab) { + const parentPath = getNativeParentPath(tab.sourcePath); + const normalizedParent = normalizeLinkedSourcePath(parentPath); + if (watchedDirectories.has(normalizedParent)) return; + watchedDirectories.add(normalizedParent); + targets.push({ key: 'folder:' + location.id + ':' + normalizedParent, path: parentPath, locationId: location.id }); + }); + }); + const seenParents = new Set(); + tabs.filter(function(tab) { return tab.sourcePath && !tab.linkedRootId; }).forEach(function(tab) { + const parentPath = getNativeParentPath(tab.sourcePath); + const key = normalizeLinkedSourcePath(parentPath); + if (!seenParents.has(key)) { + seenParents.add(key); + targets.push({ key: 'files:' + key, path: parentPath, individual: true }); + } + }); + for (const target of targets) { + try { + const watcherId = await Neutralino.filesystem.createWatcher(target.path); + linkedSourceWatchers.set(target.key, Object.assign({ id: watcherId }, target)); + } catch (error) { + console.warn('Could not watch linked location:', target.path, error); + } + } + } + + function scheduleLinkedWatcherRefresh(watcher) { + if (!watcher) return; + const key = watcher.locationId ? 'location:' + watcher.locationId : watcher.key; + clearTimeout(linkedSourceRefreshTimers.get(key)); + linkedSourceRefreshTimers.set(key, setTimeout(async function() { + linkedSourceRefreshTimers.delete(key); + if (watcher.locationId) { + await reconcileLinkedLocation(watcher.locationId); + await refreshLinkedSourceMonitoring(); + } + else { + for (const tab of tabs.filter(function(item) { return item.sourcePath && !item.linkedRootId && normalizeLinkedSourcePath(getNativeParentPath(item.sourcePath)) === normalizeLinkedSourcePath(watcher.path); })) { + await reloadLinkedDocument(tab.id); + } + } + }, 350)); + } + + async function initializeLinkedSourceMonitoring() { + if (linkedSourceMonitoringReady || typeof Neutralino === 'undefined') return; + linkedSourceMonitoringReady = true; + if (Neutralino.events && typeof Neutralino.events.on === 'function') { + await Neutralino.events.on('watchFile', function(event) { + const watcher = Array.from(linkedSourceWatchers.values()).find(function(item) { return item.id === event.detail.id; }); + scheduleLinkedWatcherRefresh(watcher); + }); + await Neutralino.events.on('windowFocus', function() { + Array.from(linkedSourceWatchers.values()).forEach(scheduleLinkedWatcherRefresh); + }); + } + await refreshLinkedSourceMonitoring(); + } + + window.NL_START_LINKED_MONITORING = function() { + return initializeLinkedSourceMonitoring(); + }; + function isTabOpen(tab) { return Boolean(tab && tab.isOpen !== false); } @@ -11624,7 +12285,7 @@ document.addEventListener("DOMContentLoaded", async function () { fileIcon.setAttribute('aria-hidden', 'true'); } else { fileIcon = document.createElement('i'); - fileIcon.className = 'lucide ' + (splitPartner ? 'lucide-columns-2' : 'lucide-file-text') + ' tab-file-icon'; + fileIcon.className = 'lucide ' + (splitPartner ? 'lucide-columns-2' : (tab.sourcePath ? 'lucide-link-2' : 'lucide-file-text')) + ' tab-file-icon'; fileIcon.setAttribute('aria-hidden', 'true'); } @@ -12732,11 +13393,12 @@ document.addEventListener("DOMContentLoaded", async function () { // Check if Neutralino passed an initial file via command line (early load) if (window.NL_INITIAL_FILE_CONTENT) { const initialFile = window.NL_INITIAL_FILE_CONTENT; - const tab = createTab(initialFile.content, initialFile.name); - tabs.push(tab); - activeTabId = tab.id; - saveTabsToStorage(tabs); - saveActiveTabId(activeTabId); + await openLinkedMarkdownDocument( + initialFile.content, + initialFile.name, + initialFile.sourcePath, + { activate: false } + ); delete window.NL_INITIAL_FILE_CONTENT; } else if (tabs.length === 0) { const tab = createTab(sampleMarkdown, 'Welcome to Markdown'); @@ -12848,14 +13510,9 @@ document.addEventListener("DOMContentLoaded", async function () { } // Late-load callback hook for Neutralino command-line files - window.NL_IMPORT_EXTERNAL_FILE = function(content, name) { + window.NL_IMPORT_EXTERNAL_FILE = async function(content, name, sourcePath) { if (typeof tabs === 'undefined') return; - const existing = tabs.find(function(t) { return t.title === name && t.content === content; }); - if (existing) { - switchTab(existing.id); - return; - } - newTab(content, name); + await openLinkedMarkdownDocument(content, name, sourcePath, { activate: true }); }; function showPreviewSkeleton() { @@ -23769,9 +24426,57 @@ ${selector} .arrowheadPath { updateStlThemes(); }); - async function nativeSaveMarkdown() { + async function writeLinkedMarkdownSource(tab, content) { + if (!tab || !tab.sourcePath) return false; + let diskContent = null; + try { + diskContent = await Neutralino.filesystem.readFile(tab.sourcePath); + } catch (_) { + const recreate = window.confirm('The linked file no longer exists:\n\n' + tab.sourcePath + '\n\nCreate it again at this location?'); + if (!recreate) return true; + } + + if (diskContent !== null && tab.sourceContentHash) { + const diskHash = await hashLinkedSourceContent(diskContent); + if (diskHash && diskHash !== tab.sourceContentHash) { + const overwrite = window.confirm( + 'This file changed outside Markdown Viewer after it was opened:\n\n' + + tab.sourcePath + + '\n\nOverwrite the external changes with the current editor content?' + ); + if (!overwrite) return true; + } + } + + await Neutralino.filesystem.writeFile(tab.sourcePath, content); + const verified = await Neutralino.filesystem.readFile(tab.sourcePath); + if (verified !== content) throw new Error('The linked file write could not be verified.'); + tab.sourceContentHash = await hashLinkedSourceContent(content); + tab.sourceMissing = false; + tab.sourceConflict = false; + tab.content = content; + tab.contentLoaded = true; + tab.lastEditedAt = Date.now(); + saveTabsToStorage(tabs, [tab.id]); + renderTabBar(tabs, activeTabId); + renderDocumentSidebar(); + showAppToast('Saved to ' + tab.sourcePath, { + tone: 'success', + title: 'Original file updated' + }); + return true; + } + + async function nativeSaveMarkdown(options) { try { + const settings = options || {}; const content = markdownEditor.value; + saveCurrentTabState(); + const activeTab = tabs.find(function(t) { return t.id === activeTabId; }); + if (settings.useLinkedSource && activeTab && activeTab.sourcePath) { + await writeLinkedMarkdownSource(activeTab, content); + return; + } const result = await Neutralino.os.showSaveDialog("Save Markdown File", { filters: [ { name: "Markdown files (*.md)", extensions: ["md", "markdown"] }, @@ -23781,12 +24486,27 @@ ${selector} .arrowheadPath { if (result) { await Neutralino.filesystem.writeFile(result, content); const fileName = result.split(/[/\\]/).pop().replace(/\.(md|markdown)$/i, ""); - const activeTab = tabs.find(function(t) { return t.id === activeTabId; }); if (activeTab) { activeTab.title = fileName; activeTab.content = content; + if (settings.useLinkedSource) { + activeTab.sourcePath = result; + activeTab.sourceContentHash = await hashLinkedSourceContent(content); + activeTab.sourceKind = 'linked'; + activeTab.sourceMissing = false; + activeTab.sourceConflict = false; + delete activeTab.linkedRootId; + delete activeTab.sourceRelativePath; + } saveTabsToStorage(tabs, [activeTab.id]); renderTabBar(tabs, activeTabId); + renderDocumentSidebar(); + if (settings.useLinkedSource) { + showAppToast('Linked to ' + result + '. Future Ctrl+S saves will update this file.', { + tone: 'success', + title: 'Linked Markdown file' + }); + } } } } catch (e) { @@ -23859,6 +24579,20 @@ ${selector} .arrowheadPath { }); } + if (linkFromFileButton) { + linkFromFileButton.addEventListener('click', function(event) { + event.preventDefault(); + void nativeLinkMarkdownFiles(); + }); + } + + if (linkFromFolderButton) { + linkFromFolderButton.addEventListener('click', function(event) { + event.preventDefault(); + void nativeLinkMarkdownFolder(); + }); + } + if (importFromGithubButton) { importFromGithubButton.addEventListener("click", function (e) { e.preventDefault(); @@ -28909,7 +29643,8 @@ ${selector} .arrowheadPath { if (isCmdOrCtrl && !e.shiftKey && key === 's') { e.preventDefault(); - exportMd.click(); + if (typeof Neutralino !== 'undefined') nativeSaveMarkdown({ useLinkedSource: true }); + else exportMd.click(); } if (isCmdOrCtrl && key === 'c') { const activeEl = document.activeElement; diff --git a/styles.css b/styles.css index 9e1692fb..094b6185 100644 --- a/styles.css +++ b/styles.css @@ -752,6 +752,20 @@ body { font-size: var(--ui-font-xs); } +.document-tree-row.is-linked-document .document-tree-main > .lucide-link-2 { + color: var(--accent-color); +} + +.document-tree-row.is-source-missing .document-tree-meta, +.document-tree-row.is-source-missing .document-tree-main > i { + color: var(--color-danger-fg); +} + +.document-tree-row.is-source-conflict .document-tree-meta { + color: #bf8700; + font-weight: 600; +} + .document-tree-main > .document-favorite-indicator { flex: 0 0 auto; color: var(--favorite-color); diff --git a/tests/e2e/desktop-linked-file.spec.js b/tests/e2e/desktop-linked-file.spec.js new file mode 100644 index 00000000..f1d902fc --- /dev/null +++ b/tests/e2e/desktop-linked-file.spec.js @@ -0,0 +1,142 @@ +const { test, expect } = require('@playwright/test'); +const { openApp, editorValue } = require('../helpers/app'); + +test('deduplicates linked source paths and writes the original only on Ctrl+S', async ({ page }) => { + await openApp(page); + await page.evaluate(async () => { + await window.NL_IMPORT_EXTERNAL_FILE('# original', 'notes', 'C:\\Docs\\notes.md'); + await window.NL_IMPORT_EXTERNAL_FILE('# changed on disk', 'notes', 'c:/docs/notes.md'); + }); + + await expect(page.locator('.tab-item .tab-title').filter({ hasText: 'notes' })).toHaveCount(1); + await expect.poll(() => editorValue(page)).toBe('# changed on disk'); + + await page.evaluate(() => { + window.__linkedDiskContent = '# changed on disk'; + window.__linkedWrites = []; + window.Neutralino = { + filesystem: { + readFile: async () => window.__linkedDiskContent, + writeFile: async (path, content) => { + window.__linkedWrites.push({ path, content }); + window.__linkedDiskContent = content; + } + }, + os: { showSaveDialog: async () => null } + }; + }); + + const editor = page.locator('#markdown-editor'); + await editor.fill('# local edit'); + await editor.dispatchEvent('input'); + await page.waitForTimeout(700); + await expect.poll(() => page.evaluate(() => window.__linkedWrites.length)).toBe(0); + + await editor.press('Control+s'); + await expect.poll(() => page.evaluate(() => window.__linkedWrites)).toEqual([ + { path: 'c:/docs/notes.md', content: '# local edit' } + ]); +}); + +test('links a folder, discovers external additions, and converts a file to a copy', async ({ page }) => { + await openApp(page); + await page.evaluate(() => { + window.__folderEntries = ['one.md']; + window.__fileContents = { + 'C:\\Notes\\one.md': '# one', + 'C:\\Notes\\two.md': '# two' + }; + window.__watchHandlers = {}; + window.__linkedWrites = []; + window.Neutralino = { + filesystem: { + readDirectory: async path => path === 'C:\\Notes' + ? window.__folderEntries.map(entry => ({ entry, type: 'FILE' })) : [], + getJoinedPath: async (...parts) => parts.join('\\'), + readFile: async path => window.__fileContents[path], + createWatcher: async () => 1, + removeWatcher: async () => 1 + }, + events: { on: async (name, handler) => { window.__watchHandlers[name] = handler; } }, + os: { + showFolderDialog: async () => 'C:\\Notes', + open: async () => ({ success: true }) + }, + storage: { setData: async () => ({ success: true }) } + }; + document.getElementById('sidebar-link-folder').click(); + }); + + await page.locator('#document-sidebar-open').click(); + await expect(page.locator('#document-tree')).toContainText('Linked Locations'); + await expect(page.locator('.document-tree-row[data-tree-type="document"]').filter({ hasText: 'one' })).toContainText('Linked'); + await page.evaluate(async () => { + await window.NL_START_LINKED_MONITORING(); + window.__folderEntries.push('two.md'); + window.__watchHandlers.watchFile({ detail: { id: 1 } }); + }); + await expect(page.locator('.document-tree-row[data-tree-type="document"]').filter({ hasText: 'two' })).toHaveCount(1); + + const linkedRow = page.locator('.document-tree-row[data-tree-type="document"]').filter({ hasText: 'one' }); + await linkedRow.dispatchEvent('contextmenu', { clientX: 120, clientY: 120 }); + const missingMenuIcons = await page.locator('.document-menu-context [role="menuitem"] i').evaluateAll(nodes => nodes + .filter(node => getComputedStyle(node).maskImage === 'none' && getComputedStyle(node).webkitMaskImage === 'none') + .map(node => node.className)); + expect(missingMenuIcons).toEqual([]); + await page.getByRole('menuitem', { name: 'Convert to Workspace copy' }).click(); + + await expect(linkedRow).not.toContainText('Linked'); + await expect(linkedRow.locator('.lucide-file-text')).toHaveCount(1); + await expect(page.locator('.document-tree-row[data-tree-type="linked-location"]').filter({ hasText: 'Notes' })).toContainText('1'); + await expect.poll(() => page.evaluate(() => window.__fileContents['C:\\Notes\\one.md'])).toBe('# one'); + await expect.poll(() => page.evaluate(() => window.__linkedWrites)).toEqual([]); +}); + +test('removing an individual link leaves the source on disk', async ({ page }) => { + await openApp(page); + await page.evaluate(() => { + window.__original = '# original'; + window.__linkedWrites = []; + window.Neutralino = { + filesystem: { + readFile: async () => window.__original, + createWatcher: async () => 1, + removeWatcher: async () => 1 + }, + os: { + showOpenDialog: async () => ['C:\\Notes\\original.md'], + open: async () => ({ success: true }) + }, + storage: { setData: async () => ({ success: true }) }, + events: { on: async () => ({ success: true }) } + }; + document.getElementById('sidebar-link-files').click(); + }); + await page.locator('#document-sidebar-open').click(); + const linkedRow = page.locator('.document-tree-row[data-tree-type="document"]').filter({ hasText: 'original' }); + await expect(linkedRow).toContainText('Linked'); + await page.evaluate(() => { + window.confirm = () => { throw new Error('browser confirm must not be used'); }; + }); + await linkedRow.dispatchEvent('contextmenu', { clientX: 120, clientY: 120 }); + await page.getByRole('menuitem', { name: 'Remove link from Workspace' }).click(); + + await expect(linkedRow).toHaveCount(0); + await expect.poll(() => page.evaluate(() => window.__original)).toBe('# original'); + await expect.poll(() => page.evaluate(() => window.__linkedWrites)).toEqual([]); + await expect(page.locator('.app-toast').filter({ hasText: 'original file remains on disk' })).toBeVisible(); +}); + +test('browser Workspace stays distinct and desktop link commands have separate icons', async ({ page }) => { + await openApp(page); + await page.locator('#document-sidebar-open').click(); + await expect(page.locator('#document-tree')).toContainText('Workspace'); + await expect(page.locator('#document-tree')).not.toContainText('Workspace (Vault)'); + await expect(page.locator('#sidebar-new-folder i')).toHaveClass(/lucide-folder-plus/); + await expect(page.locator('#sidebar-link-folder i')).toHaveClass(/lucide-folder-open/); + const icons = await page.locator('#sidebar-new-folder i, #sidebar-link-folder i').evaluateAll(nodes => nodes.map(node => ({ + mask: getComputedStyle(node).maskImage, + webkitMask: getComputedStyle(node).webkitMaskImage + }))); + expect(icons.every(icon => icon.mask !== 'none' || icon.webkitMask !== 'none')).toBeTruthy(); +}); diff --git a/workspace-storage.js b/workspace-storage.js index df3eb067..e6b40ea7 100644 --- a/workspace-storage.js +++ b/workspace-storage.js @@ -278,6 +278,7 @@ mergeCollection('workspaces'); mergeCollection('folders'); + mergeCollection('linkedLocations'); merged.ui = mergeObjectChanges(base.ui || {}, local.ui || {}, remote.ui || {}); return merged; } From 7859e8ac1698027ae745b1cbf7431c4bfca16d04 Mon Sep 17 00:00:00 2001 From: Ioannis Kechagias Date: Tue, 29 Sep 2026 03:08:51 +0300 Subject: [PATCH 02/20] docs(test): explain linked sources and adjust menu assertions --- desktop-app/README.md | 6 +++++- tests/e2e/status-toolbar-followup.spec.js | 2 +- tests/e2e/tab-split-sidebar-update.spec.js | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/desktop-app/README.md b/desktop-app/README.md index a0a78103..6f4b15e8 100644 --- a/desktop-app/README.md +++ b/desktop-app/README.md @@ -34,6 +34,10 @@ Desktop-only files: - Document metadata loads at startup; Markdown content loads only when a document is opened and is kept in a bounded in-memory cache. - Native Markdown/HTML save and Markdown open flows use Neutralino dialogs and filesystem APIs. - A Markdown file passed as a launch argument is loaded into the editor. +- Files opened from disk can remain linked to their original path. Workspace (Vault) copies are separate documents; converting a linked file to a copy leaves its source untouched. +- Linked files keep an app-local draft, but write to the original only on Ctrl+S. Reopening the same path selects its existing document rather than making another copy. +- **Link a Markdown folder** adds its Markdown files under Linked Locations. Files added or changed outside the app are detected while the desktop app is running; a locally edited draft is not silently overwritten by an external change. +- Removing a link removes the app's reference, not the original file or folder. Native file drops are not covered by the linked-file flow in this build. - The app asks before closing the window. - Prepared desktop resources load dynamic libraries from local `/libs/...` paths after setup. - Private mode pauses document-state persistence for the current page session without deleting the vault, shows an active incognito status, and resets on reload or exit. **Reset workspace** permanently deletes documents, folders, review data, settings, Secret Workspace records, history, trash, and recovery journals after confirmation. @@ -81,7 +85,7 @@ Seven self-contained executables are written to `desktop-app/dist/markdown-viewe | Token security | One-time | | Logging | Disabled | -Native APIs are intentionally allowlisted: app exit, open/save dialogs, message boxes, external URL/folder open, tray setup, scoped file and folder operations, path lookup, and storage get/set/remove. Command execution is not part of the default allowlist. +Native APIs are intentionally allowlisted: app exit, open/save/folder dialogs, message boxes, external URL/folder open, tray setup, scoped file and folder operations, filesystem watchers, path lookup, and storage get/set/remove. Command execution is not part of the default allowlist. ## Local Renderer Security diff --git a/tests/e2e/status-toolbar-followup.spec.js b/tests/e2e/status-toolbar-followup.spec.js index af7d09ce..0dfdb659 100644 --- a/tests/e2e/status-toolbar-followup.spec.js +++ b/tests/e2e/status-toolbar-followup.spec.js @@ -88,7 +88,7 @@ test('header consolidates icon document actions in the requested order', async ( await header.locator('#importDropdown').click(); const newMenu = page.locator('[aria-labelledby="importDropdown"]'); await expect(newMenu).toBeVisible(); - await expect(newMenu.locator('.app-menu-label')).toHaveText(['New document', 'From files', 'From GitHub']); + await expect(newMenu.locator('.app-menu-label:visible')).toHaveText(['New document', 'From files', 'From GitHub']); // Verify the layout width, not the transient visual scale during menu-open motion. const newWidth = await newMenu.evaluate(menu => menu.offsetWidth); expect(newWidth).toBeGreaterThanOrEqual(167.5); diff --git a/tests/e2e/tab-split-sidebar-update.spec.js b/tests/e2e/tab-split-sidebar-update.spec.js index 38187fb1..ba5b6365 100644 --- a/tests/e2e/tab-split-sidebar-update.spec.js +++ b/tests/e2e/tab-split-sidebar-update.spec.js @@ -330,7 +330,7 @@ test('header groups document actions before application preferences', async ({ p await header.locator('#importDropdown').click(); const newMenu = header.locator('[aria-labelledby="importDropdown"]'); await expect(newMenu).toBeVisible(); - await expect(newMenu.locator('.app-menu-label')).toHaveText(['New document', 'From files', 'From GitHub']); + await expect(newMenu.locator('.app-menu-label:visible')).toHaveText(['New document', 'From files', 'From GitHub']); await expect(newMenu.locator('.settings-menu-header, .app-menu-description')).toHaveCount(0); await newMenu.locator('#header-new-document').click(); await expect(page.locator('#tab-list .tab-item')).toHaveCount(initialTabCount + 1); From 0bdf5290788df4627d33c988920d074d8fe2314a Mon Sep 17 00:00:00 2001 From: Ioannis Kechagias Date: Sat, 3 Oct 2026 16:50:25 +0300 Subject: [PATCH 03/20] fix(desktop): distinguish linked files and folders with symlink icons --- assets/lucide-icons.css | 3 +++ desktop-app/resources/js/script.js | 14 +++++++------- script.js | 14 +++++++------- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/assets/lucide-icons.css b/assets/lucide-icons.css index 9d216ce9..79318c61 100644 --- a/assets/lucide-icons.css +++ b/assets/lucide-icons.css @@ -3,6 +3,9 @@ * Source: https://lucide.dev/ • ISC License * Only icons used by Markdown Viewer are embedded with a 1.5px stroke. No runtime JavaScript or webfont is loaded. */ +.lucide-file-symlink { --lucide-icon: url("data:image/svg+xml;base64,PCEtLSBAbGljZW5zZSBsdWNpZGUtc3RhdGljIHYxLjI1LjAgLSBJU0MgLS0+PHN2ZyBjbGFzcz0ibHVjaWRlIGx1Y2lkZS1maWxlLXN5bWxpbmsiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9Im5vbmUiIHN0cm9rZT0iYmxhY2siIHN0cm9rZS13aWR0aD0iMS41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik00IDExVjRhMiAyIDAgMCAxIDItMmg4YTIuNCAyLjQgMCAwIDEgMS43MDYuNzA2bDMuNTg4IDMuNTg4QTIuNCAyLjQgMCAwIDEgMjAgOHYxMmEyIDIgMCAwIDEtMiAySDZhMiAyIDAgMCAxLTItMnYtM2EyIDIgMCAwIDEgMi0yaDciLz48cGF0aCBkPSJNMTQgMnY1YTEgMSAwIDAgMCAxIDFoNSIvPjxwYXRoIGQ9Im0xMCAxOCAzLTMtMy0zIi8+PC9zdmc+"); } +.lucide-folder-symlink { --lucide-icon: url("data:image/svg+xml;base64,PCEtLSBAbGljZW5zZSBsdWNpZGUtc3RhdGljIHYxLjI1LjAgLSBJU0MgLS0+PHN2ZyBjbGFzcz0ibHVjaWRlIGx1Y2lkZS1mb2xkZXItc3ltbGluayIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHBhdGggZD0iTTIgOS4zNVY1YTIgMiAwIDAgMSAyLTJoMy45YTIgMiAwIDAgMSAxLjY5LjlsLjgxIDEuMmEyIDIgMCAwIDAgMS42Ny45SDIwYTIgMiAwIDAgMSAyIDJ2MTBhMiAyIDAgMCAxLTIgMkg0YTIgMiAwIDAgMS0yLTJ2LTNhMiAyIDAgMCAxIDItMmg3Ii8+PHBhdGggZD0ibTggMTYgMy0zLTMtMyIvPjwvc3ZnPg=="); } + .lucide { display: inline-block; width: 1em; diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index 681c6446..9fd07c48 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -5891,9 +5891,9 @@ document.addEventListener("DOMContentLoaded", async function () { function getDocumentMenuActions(tab) { if (tab && tab.sourcePath && !isTemporaryDocument(tab)) { const linkedActions = [ - { id: 'open', icon: 'lucide-file-text', label: 'Open', run: function() { openSidebarDocument(tab.id); } }, + { id: 'open', icon: 'lucide-file-symlink', label: 'Open', run: function() { openSidebarDocument(tab.id); } }, { id: 'reload-source', icon: 'lucide-refresh-cw', label: 'Reload from disk', run: function() { void reloadLinkedDocument(tab.id, { force: true }); } }, - { id: 'reveal-source', icon: 'lucide-folder-open', label: 'Open containing folder', run: function() { void revealLinkedSource(tab); } }, + { id: 'reveal-source', icon: 'lucide-folder-symlink', label: 'Open containing folder', run: function() { void revealLinkedSource(tab); } }, { id: 'copy-source-path', icon: 'lucide-copy', label: 'Copy source path', run: function() { void copyTextToClipboard(tab.sourcePath); } }, { separator: true }, { id: 'convert-vault', icon: 'lucide-files', label: 'Convert to Workspace copy', run: function() { void convertLinkedDocumentToVault(tab.id); } }, @@ -6729,7 +6729,7 @@ document.addEventListener("DOMContentLoaded", async function () { documentId: tab.id, label: tab.title || 'Untitled', ariaLabel: (tab.id === activeTabId ? 'Active document, ' : 'Document, ') + (tab.title || 'Untitled') + (tab.sourcePath ? ', linked file' : '') + (tab.favorite ? ', favorite' : ''), - icon: tab.sourcePath ? 'lucide-link-2' : 'lucide-file-text', + icon: tab.sourcePath ? 'lucide-file-symlink' : 'lucide-file-text', depth: depth, favorite: tab.favorite === true, temporary: isTemporaryDocument(tab), @@ -6810,7 +6810,7 @@ document.addEventListener("DOMContentLoaded", async function () { function getLinkedLocationMenuActions(location) { return [ { id: 'rescan', icon: 'lucide-refresh-cw', label: 'Rescan folder', run: function() { void reconcileLinkedLocation(location.id, { notify: true }); } }, - { id: 'reveal', icon: 'lucide-folder-open', label: 'Open folder', run: function() { void Neutralino.os.open(location.path); } }, + { id: 'reveal', icon: 'lucide-folder-symlink', label: 'Open folder', run: function() { void Neutralino.os.open(location.path); } }, { id: 'unlink', icon: 'lucide-x', label: 'Remove linked folder', run: function() { void removeLinkedLocation(location.id); } } ]; } @@ -6867,7 +6867,7 @@ document.addEventListener("DOMContentLoaded", async function () { id: location.id, label: location.name, ariaLabel: 'Linked folder, ' + location.path + (location.unavailable ? ', unavailable' : ''), - icon: location.unavailable ? 'lucide-triangle-alert' : (expanded ? 'lucide-folder-open' : 'lucide-folder'), + icon: location.unavailable ? 'lucide-triangle-alert' : 'lucide-folder-symlink', depth: 0, expanded: expanded, meta: location.unavailable ? 'Unavailable' : String(locationTabs.length), @@ -6908,7 +6908,7 @@ document.addEventListener("DOMContentLoaded", async function () { type: 'linked-virtual-folder', id: location.id + ':' + pathParts.concat(folderName).join('/'), label: folderName, - icon: 'lucide-folder-open', + icon: 'lucide-folder-symlink', depth: depth, expanded: true, meta: '', @@ -12289,7 +12289,7 @@ document.addEventListener("DOMContentLoaded", async function () { fileIcon.setAttribute('aria-hidden', 'true'); } else { fileIcon = document.createElement('i'); - fileIcon.className = 'lucide ' + (splitPartner ? 'lucide-columns-2' : (tab.sourcePath ? 'lucide-link-2' : 'lucide-file-text')) + ' tab-file-icon'; + fileIcon.className = 'lucide ' + (splitPartner ? 'lucide-columns-2' : (tab.sourcePath ? 'lucide-file-symlink' : 'lucide-file-text')) + ' tab-file-icon'; fileIcon.setAttribute('aria-hidden', 'true'); } diff --git a/script.js b/script.js index 681c6446..9fd07c48 100644 --- a/script.js +++ b/script.js @@ -5891,9 +5891,9 @@ document.addEventListener("DOMContentLoaded", async function () { function getDocumentMenuActions(tab) { if (tab && tab.sourcePath && !isTemporaryDocument(tab)) { const linkedActions = [ - { id: 'open', icon: 'lucide-file-text', label: 'Open', run: function() { openSidebarDocument(tab.id); } }, + { id: 'open', icon: 'lucide-file-symlink', label: 'Open', run: function() { openSidebarDocument(tab.id); } }, { id: 'reload-source', icon: 'lucide-refresh-cw', label: 'Reload from disk', run: function() { void reloadLinkedDocument(tab.id, { force: true }); } }, - { id: 'reveal-source', icon: 'lucide-folder-open', label: 'Open containing folder', run: function() { void revealLinkedSource(tab); } }, + { id: 'reveal-source', icon: 'lucide-folder-symlink', label: 'Open containing folder', run: function() { void revealLinkedSource(tab); } }, { id: 'copy-source-path', icon: 'lucide-copy', label: 'Copy source path', run: function() { void copyTextToClipboard(tab.sourcePath); } }, { separator: true }, { id: 'convert-vault', icon: 'lucide-files', label: 'Convert to Workspace copy', run: function() { void convertLinkedDocumentToVault(tab.id); } }, @@ -6729,7 +6729,7 @@ document.addEventListener("DOMContentLoaded", async function () { documentId: tab.id, label: tab.title || 'Untitled', ariaLabel: (tab.id === activeTabId ? 'Active document, ' : 'Document, ') + (tab.title || 'Untitled') + (tab.sourcePath ? ', linked file' : '') + (tab.favorite ? ', favorite' : ''), - icon: tab.sourcePath ? 'lucide-link-2' : 'lucide-file-text', + icon: tab.sourcePath ? 'lucide-file-symlink' : 'lucide-file-text', depth: depth, favorite: tab.favorite === true, temporary: isTemporaryDocument(tab), @@ -6810,7 +6810,7 @@ document.addEventListener("DOMContentLoaded", async function () { function getLinkedLocationMenuActions(location) { return [ { id: 'rescan', icon: 'lucide-refresh-cw', label: 'Rescan folder', run: function() { void reconcileLinkedLocation(location.id, { notify: true }); } }, - { id: 'reveal', icon: 'lucide-folder-open', label: 'Open folder', run: function() { void Neutralino.os.open(location.path); } }, + { id: 'reveal', icon: 'lucide-folder-symlink', label: 'Open folder', run: function() { void Neutralino.os.open(location.path); } }, { id: 'unlink', icon: 'lucide-x', label: 'Remove linked folder', run: function() { void removeLinkedLocation(location.id); } } ]; } @@ -6867,7 +6867,7 @@ document.addEventListener("DOMContentLoaded", async function () { id: location.id, label: location.name, ariaLabel: 'Linked folder, ' + location.path + (location.unavailable ? ', unavailable' : ''), - icon: location.unavailable ? 'lucide-triangle-alert' : (expanded ? 'lucide-folder-open' : 'lucide-folder'), + icon: location.unavailable ? 'lucide-triangle-alert' : 'lucide-folder-symlink', depth: 0, expanded: expanded, meta: location.unavailable ? 'Unavailable' : String(locationTabs.length), @@ -6908,7 +6908,7 @@ document.addEventListener("DOMContentLoaded", async function () { type: 'linked-virtual-folder', id: location.id + ':' + pathParts.concat(folderName).join('/'), label: folderName, - icon: 'lucide-folder-open', + icon: 'lucide-folder-symlink', depth: depth, expanded: true, meta: '', @@ -12289,7 +12289,7 @@ document.addEventListener("DOMContentLoaded", async function () { fileIcon.setAttribute('aria-hidden', 'true'); } else { fileIcon = document.createElement('i'); - fileIcon.className = 'lucide ' + (splitPartner ? 'lucide-columns-2' : (tab.sourcePath ? 'lucide-link-2' : 'lucide-file-text')) + ' tab-file-icon'; + fileIcon.className = 'lucide ' + (splitPartner ? 'lucide-columns-2' : (tab.sourcePath ? 'lucide-file-symlink' : 'lucide-file-text')) + ' tab-file-icon'; fileIcon.setAttribute('aria-hidden', 'true'); } From 00ebcc664958b7ec93ebfdf5fa7bf61e93333ac5 Mon Sep 17 00:00:00 2001 From: Ioannis Kechagias Date: Sat, 3 Oct 2026 16:50:26 +0300 Subject: [PATCH 04/20] fix(sidebar): group linked documents under Workspaces and persist folder expansion --- desktop-app/resources/js/script.js | 144 +++++++++++++++++++---------- desktop-app/resources/styles.css | 7 +- script.js | 144 +++++++++++++++++++---------- styles.css | 7 +- 4 files changed, 192 insertions(+), 110 deletions(-) diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index 9fd07c48..a78c53d0 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -4297,6 +4297,7 @@ document.addEventListener("DOMContentLoaded", async function () { linkedLocations: [], ui: { filter: 'all', + linkedWorkspaceExpanded: true, collapsed: false, width: 288, lastWorkspaceId: DEFAULT_WORKSPACE_ID, @@ -4367,6 +4368,9 @@ document.addEventListener("DOMContentLoaded", async function () { path: path, name: String(location.name || '').trim().slice(0, 160) || path.split(/[/\\]/).filter(Boolean).pop() || 'Linked folder', expanded: location.expanded !== false, + collapsedFolderPaths: Array.from(new Set((Array.isArray(location.collapsedFolderPaths) ? location.collapsedFolderPaths : []) + .map(function(item) { return String(item || '').replace(/\\/g, '/').replace(/^\/+/, ''); }) + .filter(Boolean))), excludedPaths: Array.from(new Set((Array.isArray(location.excludedPaths) ? location.excludedPaths : []) .map(function(item) { return String(item || '').replace(/\\/g, '/').replace(/^\/+/, ''); }) .filter(Boolean))), @@ -4390,6 +4394,7 @@ document.addEventListener("DOMContentLoaded", async function () { linkedLocations: linkedLocations, ui: { filter: allowedFilters.has(ui.filter) ? ui.filter : 'all', + linkedWorkspaceExpanded: ui.linkedWorkspaceExpanded !== false, collapsed: ui.collapsed === true, width: Number.isFinite(width) ? Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, width)) : 288, lastWorkspaceId: lastWorkspaceId, @@ -4451,6 +4456,7 @@ document.addEventListener("DOMContentLoaded", async function () { path: location.path, name: location.name, expanded: location.expanded !== false, + collapsedFolderPaths: Array.isArray(location.collapsedFolderPaths) ? location.collapsedFolderPaths.slice() : [], excludedPaths: Array.isArray(location.excludedPaths) ? location.excludedPaths.slice() : [], createdAt: location.createdAt, lastScanAt: location.lastScanAt || 0, @@ -6606,6 +6612,7 @@ document.addEventListener("DOMContentLoaded", async function () { if (options.favorite) row.classList.add('is-favorite'); if (options.temporary) row.classList.add('document-tree-temporary'); if (options.locked) row.classList.add('is-secret-locked'); + if (options.currentLocation) row.classList.add('is-current-location'); if (typeof options.expanded === 'boolean') row.setAttribute('aria-expanded', options.expanded ? 'true' : 'false'); row.setAttribute('aria-label', options.ariaLabel || options.label); @@ -6804,7 +6811,7 @@ document.addEventListener("DOMContentLoaded", async function () { function getLinkedDocumentMeta(tab) { if (tab.sourceMissing) return 'Missing source'; if (tab.sourceConflict) return 'Conflict'; - return 'Linked · recovery in Vault'; + return ''; } function getLinkedLocationMenuActions(location) { @@ -6821,38 +6828,39 @@ document.addEventListener("DOMContentLoaded", async function () { return !isTemporaryDocument(tab) && Boolean(tab.sourcePath); }); const locations = Array.isArray(documentOrganization.linkedLocations) ? documentOrganization.linkedLocations : []; - if (!linkedTabs.length && !locations.length) return 0; - let renderedDocuments = 0; - const sectionLabel = document.createElement('p'); - sectionLabel.className = 'document-tree-section-label'; - sectionLabel.textContent = 'Linked Locations'; - tree.appendChild(sectionLabel); + const activeDocument = tabs.find(function(tab) { return tab.id === activeTabId; }); + const workspaceExpanded = documentSidebarSearch ? true : documentOrganization.ui.linkedWorkspaceExpanded !== false; + const toggleWorkspace = function() { + documentOrganization.ui.linkedWorkspaceExpanded = !workspaceExpanded; + saveDocumentOrganization(); + renderDocumentSidebar(); + }; + tree.appendChild(createDocumentTreeRow({ + type: 'linked-workspace', + id: 'linked-workspace', + label: 'Linked Workspace', + ariaLabel: 'Linked Workspace, original files remain on disk', + icon: 'lucide-link-2', + depth: 0, + expanded: workspaceExpanded, + currentLocation: Boolean(activeDocument && activeDocument.sourcePath), + meta: String(linkedTabs.length), + onToggle: toggleWorkspace, + onActivate: toggleWorkspace + })); + const workspaceGroup = document.createElement('div'); + workspaceGroup.className = 'document-tree-group document-tree-group--workspace'; + workspaceGroup.setAttribute('role', 'group'); + workspaceGroup.hidden = !workspaceExpanded; + tree.appendChild(workspaceGroup); const individualTabs = linkedTabs.filter(function(tab) { return !tab.linkedRootId; }) .filter(documentMatchesSidebarSearch) .sort(function(left, right) { return (left.title || '').localeCompare(right.title || ''); }); - if (individualTabs.length) { - const individualRow = createDocumentTreeRow({ - type: 'linked-location', - id: 'linked-files', - label: 'Linked files', - ariaLabel: 'Linked files, original files remain on disk', - icon: 'lucide-link-2', - depth: 0, - expanded: true, - meta: String(individualTabs.length), - onToggle: function() {} - }); - tree.appendChild(individualRow); - const group = document.createElement('div'); - group.className = 'document-tree-group document-tree-group--workspace'; - group.setAttribute('role', 'group'); - individualTabs.forEach(function(tab) { - if (appendDocumentTreeItem(group, tab, 1, getLinkedDocumentMeta(tab))) renderedDocuments++; - }); - tree.appendChild(group); - } + individualTabs.forEach(function(tab) { + if (appendDocumentTreeItem(workspaceGroup, tab, 1, getLinkedDocumentMeta(tab))) renderedDocuments++; + }); locations.slice().sort(function(left, right) { return (left.name || '').localeCompare(right.name || ''); @@ -6862,31 +6870,29 @@ document.addEventListener("DOMContentLoaded", async function () { const locationMatches = !documentSidebarSearch || (location.name + ' ' + location.path).toLocaleLowerCase().includes(documentSidebarSearch.toLocaleLowerCase()); if (documentSidebarSearch && !locationMatches && !matchingTabs.length) return; const expanded = documentSidebarSearch ? true : location.expanded !== false; + const toggleLocation = function() { + location.expanded = !expanded; + saveDocumentOrganization(); + renderDocumentSidebar(); + }; const rootRow = createDocumentTreeRow({ type: 'linked-location', id: location.id, label: location.name, ariaLabel: 'Linked folder, ' + location.path + (location.unavailable ? ', unavailable' : ''), icon: location.unavailable ? 'lucide-triangle-alert' : 'lucide-folder-symlink', - depth: 0, + depth: 1, + currentLocation: Boolean(activeDocument && activeDocument.linkedRootId === location.id), expanded: expanded, meta: location.unavailable ? 'Unavailable' : String(locationTabs.length), menuActions: getLinkedLocationMenuActions(location), - onToggle: function() { - location.expanded = !expanded; - saveDocumentOrganization(); - renderDocumentSidebar(); - }, - onActivate: function() { - location.expanded = true; - saveDocumentOrganization(); - renderDocumentSidebar(); - } + onToggle: toggleLocation, + onActivate: toggleLocation }); - tree.appendChild(rootRow); + workspaceGroup.appendChild(rootRow); const rootGroup = document.createElement('div'); - rootGroup.className = 'document-tree-group document-tree-group--workspace'; + rootGroup.className = 'document-tree-group document-tree-group--folder'; rootGroup.setAttribute('role', 'group'); rootGroup.hidden = !expanded; const rootNode = { folders: new Map(), documents: [] }; @@ -6904,20 +6910,33 @@ document.addEventListener("DOMContentLoaded", async function () { Array.from(node.folders.entries()).sort(function(left, right) { return left[0].localeCompare(right[0]); }).forEach(function(entry) { const folderName = entry[0]; const child = entry[1]; + const folderPath = pathParts.concat(folderName).join('/'); + const collapsedPaths = location.collapsedFolderPaths || []; + const folderExpanded = documentSidebarSearch ? true : !collapsedPaths.includes(folderPath); + const toggleFolder = function() { + const nextCollapsed = new Set(location.collapsedFolderPaths || []); + if (folderExpanded) nextCollapsed.add(folderPath); + else nextCollapsed.delete(folderPath); + location.collapsedFolderPaths = Array.from(nextCollapsed); + saveDocumentOrganization(); + renderDocumentSidebar(); + }; const row = createDocumentTreeRow({ type: 'linked-virtual-folder', - id: location.id + ':' + pathParts.concat(folderName).join('/'), + id: location.id + ':' + folderPath, label: folderName, icon: 'lucide-folder-symlink', depth: depth, - expanded: true, + expanded: folderExpanded, meta: '', - onToggle: function() {} + onToggle: toggleFolder, + onActivate: toggleFolder }); container.appendChild(row); const childGroup = document.createElement('div'); childGroup.className = 'document-tree-group document-tree-group--folder'; childGroup.setAttribute('role', 'group'); + childGroup.hidden = !folderExpanded; appendVirtualNode(child, childGroup, depth + 1, pathParts.concat(folderName)); container.appendChild(childGroup); }); @@ -6925,8 +6944,8 @@ document.addEventListener("DOMContentLoaded", async function () { if (appendDocumentTreeItem(container, tab, depth, getLinkedDocumentMeta(tab))) renderedDocuments++; }); }; - appendVirtualNode(rootNode, rootGroup, 1, []); - tree.appendChild(rootGroup); + appendVirtualNode(rootNode, rootGroup, 2, []); + workspaceGroup.appendChild(rootGroup); }); return renderedDocuments; } @@ -6948,10 +6967,9 @@ document.addEventListener("DOMContentLoaded", async function () { }); } - renderedDocuments += renderLinkedLocationsTree(tree); - documentOrganization.workspaces.forEach(function(workspace) { const isSecretWorkspace = workspace.id === SECRET_WORKSPACE_ID; + if (isSecretWorkspace) renderedDocuments += renderLinkedLocationsTree(tree); const secretLocked = isSecretWorkspace && !isSecretWorkspaceUnlocked(); const workspaceDocuments = tabs.filter(function(tab) { return !isTemporaryDocument(tab) && !tab.sourcePath && tab.workspaceId === workspace.id; @@ -6985,6 +7003,7 @@ document.addEventListener("DOMContentLoaded", async function () { ? (secretLocked ? 'lucide-shield' : 'lucide-shield-check') : (expanded ? 'lucide-folder-open' : 'lucide-folder'), depth: 0, + currentLocation: tabs.some(function(tab) { return tab.id === activeTabId && !tab.sourcePath && tab.workspaceId === workspace.id; }), expanded: expanded, meta: secretLocked ? 'Locked' : String(workspaceDocuments.length), locked: secretLocked, @@ -7264,11 +7283,38 @@ document.addEventListener("DOMContentLoaded", async function () { const unlockedWorkspaceIds = new Set(documentOrganization.workspaces.filter(function(workspace) { return workspace.id !== SECRET_WORKSPACE_ID || isSecretWorkspaceUnlocked(); }).map(function(workspace) { return workspace.id; })); - return documentOrganization.workspaces.filter(function(workspace) { + const items = documentOrganization.workspaces.filter(function(workspace) { return unlockedWorkspaceIds.has(workspace.id); }).concat(documentOrganization.folders.filter(function(folder) { return unlockedWorkspaceIds.has(folder.workspaceId); })); + if (typeof Neutralino !== 'undefined') { + items.push({ + get expanded() { return documentOrganization.ui.linkedWorkspaceExpanded !== false; }, + set expanded(value) { documentOrganization.ui.linkedWorkspaceExpanded = value; } + }); + (documentOrganization.linkedLocations || []).forEach(function(location) { + items.push(location); + const folderPaths = new Set(location.collapsedFolderPaths || []); + tabs.filter(function(tab) { return tab.sourcePath && tab.linkedRootId === location.id; }).forEach(function(tab) { + const parts = normalizeLinkedRelativePath(tab.sourceRelativePath).split('/').filter(Boolean); + parts.pop(); + parts.forEach(function(_, index) { folderPaths.add(parts.slice(0, index + 1).join('/')); }); + }); + folderPaths.forEach(function(folderPath) { + items.push({ + get expanded() { return !(location.collapsedFolderPaths || []).includes(folderPath); }, + set expanded(value) { + const collapsed = new Set(location.collapsedFolderPaths || []); + if (value) collapsed.delete(folderPath); + else collapsed.add(folderPath); + location.collapsedFolderPaths = Array.from(collapsed); + } + }); + }); + }); + } + return items; } function updateTreeExpansionButton(button, willExpand, label, enabled) { diff --git a/desktop-app/resources/styles.css b/desktop-app/resources/styles.css index 5b4fd3a0..8536ce39 100644 --- a/desktop-app/resources/styles.css +++ b/desktop-app/resources/styles.css @@ -592,7 +592,6 @@ body { background: color-mix(in srgb, var(--accent-color) 7%, transparent); } -.document-tree-row[data-tree-type="document"].is-selected > .document-tree-main > i:first-child, .document-tree-row[data-tree-type="document"].is-active > .document-tree-main > i:first-child { color: var(--accent-color); } @@ -727,7 +726,7 @@ body { text-align: center; } -.document-tree-main > .lucide-folder-open { +.document-tree-row.is-current-location > .document-tree-main > i:first-child { color: var(--accent-color); } @@ -752,10 +751,6 @@ body { font-size: var(--ui-font-xs); } -.document-tree-row.is-linked-document .document-tree-main > .lucide-link-2 { - color: var(--accent-color); -} - .document-tree-row.is-source-missing .document-tree-meta, .document-tree-row.is-source-missing .document-tree-main > i { color: var(--color-danger-fg); diff --git a/script.js b/script.js index 9fd07c48..a78c53d0 100644 --- a/script.js +++ b/script.js @@ -4297,6 +4297,7 @@ document.addEventListener("DOMContentLoaded", async function () { linkedLocations: [], ui: { filter: 'all', + linkedWorkspaceExpanded: true, collapsed: false, width: 288, lastWorkspaceId: DEFAULT_WORKSPACE_ID, @@ -4367,6 +4368,9 @@ document.addEventListener("DOMContentLoaded", async function () { path: path, name: String(location.name || '').trim().slice(0, 160) || path.split(/[/\\]/).filter(Boolean).pop() || 'Linked folder', expanded: location.expanded !== false, + collapsedFolderPaths: Array.from(new Set((Array.isArray(location.collapsedFolderPaths) ? location.collapsedFolderPaths : []) + .map(function(item) { return String(item || '').replace(/\\/g, '/').replace(/^\/+/, ''); }) + .filter(Boolean))), excludedPaths: Array.from(new Set((Array.isArray(location.excludedPaths) ? location.excludedPaths : []) .map(function(item) { return String(item || '').replace(/\\/g, '/').replace(/^\/+/, ''); }) .filter(Boolean))), @@ -4390,6 +4394,7 @@ document.addEventListener("DOMContentLoaded", async function () { linkedLocations: linkedLocations, ui: { filter: allowedFilters.has(ui.filter) ? ui.filter : 'all', + linkedWorkspaceExpanded: ui.linkedWorkspaceExpanded !== false, collapsed: ui.collapsed === true, width: Number.isFinite(width) ? Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, width)) : 288, lastWorkspaceId: lastWorkspaceId, @@ -4451,6 +4456,7 @@ document.addEventListener("DOMContentLoaded", async function () { path: location.path, name: location.name, expanded: location.expanded !== false, + collapsedFolderPaths: Array.isArray(location.collapsedFolderPaths) ? location.collapsedFolderPaths.slice() : [], excludedPaths: Array.isArray(location.excludedPaths) ? location.excludedPaths.slice() : [], createdAt: location.createdAt, lastScanAt: location.lastScanAt || 0, @@ -6606,6 +6612,7 @@ document.addEventListener("DOMContentLoaded", async function () { if (options.favorite) row.classList.add('is-favorite'); if (options.temporary) row.classList.add('document-tree-temporary'); if (options.locked) row.classList.add('is-secret-locked'); + if (options.currentLocation) row.classList.add('is-current-location'); if (typeof options.expanded === 'boolean') row.setAttribute('aria-expanded', options.expanded ? 'true' : 'false'); row.setAttribute('aria-label', options.ariaLabel || options.label); @@ -6804,7 +6811,7 @@ document.addEventListener("DOMContentLoaded", async function () { function getLinkedDocumentMeta(tab) { if (tab.sourceMissing) return 'Missing source'; if (tab.sourceConflict) return 'Conflict'; - return 'Linked · recovery in Vault'; + return ''; } function getLinkedLocationMenuActions(location) { @@ -6821,38 +6828,39 @@ document.addEventListener("DOMContentLoaded", async function () { return !isTemporaryDocument(tab) && Boolean(tab.sourcePath); }); const locations = Array.isArray(documentOrganization.linkedLocations) ? documentOrganization.linkedLocations : []; - if (!linkedTabs.length && !locations.length) return 0; - let renderedDocuments = 0; - const sectionLabel = document.createElement('p'); - sectionLabel.className = 'document-tree-section-label'; - sectionLabel.textContent = 'Linked Locations'; - tree.appendChild(sectionLabel); + const activeDocument = tabs.find(function(tab) { return tab.id === activeTabId; }); + const workspaceExpanded = documentSidebarSearch ? true : documentOrganization.ui.linkedWorkspaceExpanded !== false; + const toggleWorkspace = function() { + documentOrganization.ui.linkedWorkspaceExpanded = !workspaceExpanded; + saveDocumentOrganization(); + renderDocumentSidebar(); + }; + tree.appendChild(createDocumentTreeRow({ + type: 'linked-workspace', + id: 'linked-workspace', + label: 'Linked Workspace', + ariaLabel: 'Linked Workspace, original files remain on disk', + icon: 'lucide-link-2', + depth: 0, + expanded: workspaceExpanded, + currentLocation: Boolean(activeDocument && activeDocument.sourcePath), + meta: String(linkedTabs.length), + onToggle: toggleWorkspace, + onActivate: toggleWorkspace + })); + const workspaceGroup = document.createElement('div'); + workspaceGroup.className = 'document-tree-group document-tree-group--workspace'; + workspaceGroup.setAttribute('role', 'group'); + workspaceGroup.hidden = !workspaceExpanded; + tree.appendChild(workspaceGroup); const individualTabs = linkedTabs.filter(function(tab) { return !tab.linkedRootId; }) .filter(documentMatchesSidebarSearch) .sort(function(left, right) { return (left.title || '').localeCompare(right.title || ''); }); - if (individualTabs.length) { - const individualRow = createDocumentTreeRow({ - type: 'linked-location', - id: 'linked-files', - label: 'Linked files', - ariaLabel: 'Linked files, original files remain on disk', - icon: 'lucide-link-2', - depth: 0, - expanded: true, - meta: String(individualTabs.length), - onToggle: function() {} - }); - tree.appendChild(individualRow); - const group = document.createElement('div'); - group.className = 'document-tree-group document-tree-group--workspace'; - group.setAttribute('role', 'group'); - individualTabs.forEach(function(tab) { - if (appendDocumentTreeItem(group, tab, 1, getLinkedDocumentMeta(tab))) renderedDocuments++; - }); - tree.appendChild(group); - } + individualTabs.forEach(function(tab) { + if (appendDocumentTreeItem(workspaceGroup, tab, 1, getLinkedDocumentMeta(tab))) renderedDocuments++; + }); locations.slice().sort(function(left, right) { return (left.name || '').localeCompare(right.name || ''); @@ -6862,31 +6870,29 @@ document.addEventListener("DOMContentLoaded", async function () { const locationMatches = !documentSidebarSearch || (location.name + ' ' + location.path).toLocaleLowerCase().includes(documentSidebarSearch.toLocaleLowerCase()); if (documentSidebarSearch && !locationMatches && !matchingTabs.length) return; const expanded = documentSidebarSearch ? true : location.expanded !== false; + const toggleLocation = function() { + location.expanded = !expanded; + saveDocumentOrganization(); + renderDocumentSidebar(); + }; const rootRow = createDocumentTreeRow({ type: 'linked-location', id: location.id, label: location.name, ariaLabel: 'Linked folder, ' + location.path + (location.unavailable ? ', unavailable' : ''), icon: location.unavailable ? 'lucide-triangle-alert' : 'lucide-folder-symlink', - depth: 0, + depth: 1, + currentLocation: Boolean(activeDocument && activeDocument.linkedRootId === location.id), expanded: expanded, meta: location.unavailable ? 'Unavailable' : String(locationTabs.length), menuActions: getLinkedLocationMenuActions(location), - onToggle: function() { - location.expanded = !expanded; - saveDocumentOrganization(); - renderDocumentSidebar(); - }, - onActivate: function() { - location.expanded = true; - saveDocumentOrganization(); - renderDocumentSidebar(); - } + onToggle: toggleLocation, + onActivate: toggleLocation }); - tree.appendChild(rootRow); + workspaceGroup.appendChild(rootRow); const rootGroup = document.createElement('div'); - rootGroup.className = 'document-tree-group document-tree-group--workspace'; + rootGroup.className = 'document-tree-group document-tree-group--folder'; rootGroup.setAttribute('role', 'group'); rootGroup.hidden = !expanded; const rootNode = { folders: new Map(), documents: [] }; @@ -6904,20 +6910,33 @@ document.addEventListener("DOMContentLoaded", async function () { Array.from(node.folders.entries()).sort(function(left, right) { return left[0].localeCompare(right[0]); }).forEach(function(entry) { const folderName = entry[0]; const child = entry[1]; + const folderPath = pathParts.concat(folderName).join('/'); + const collapsedPaths = location.collapsedFolderPaths || []; + const folderExpanded = documentSidebarSearch ? true : !collapsedPaths.includes(folderPath); + const toggleFolder = function() { + const nextCollapsed = new Set(location.collapsedFolderPaths || []); + if (folderExpanded) nextCollapsed.add(folderPath); + else nextCollapsed.delete(folderPath); + location.collapsedFolderPaths = Array.from(nextCollapsed); + saveDocumentOrganization(); + renderDocumentSidebar(); + }; const row = createDocumentTreeRow({ type: 'linked-virtual-folder', - id: location.id + ':' + pathParts.concat(folderName).join('/'), + id: location.id + ':' + folderPath, label: folderName, icon: 'lucide-folder-symlink', depth: depth, - expanded: true, + expanded: folderExpanded, meta: '', - onToggle: function() {} + onToggle: toggleFolder, + onActivate: toggleFolder }); container.appendChild(row); const childGroup = document.createElement('div'); childGroup.className = 'document-tree-group document-tree-group--folder'; childGroup.setAttribute('role', 'group'); + childGroup.hidden = !folderExpanded; appendVirtualNode(child, childGroup, depth + 1, pathParts.concat(folderName)); container.appendChild(childGroup); }); @@ -6925,8 +6944,8 @@ document.addEventListener("DOMContentLoaded", async function () { if (appendDocumentTreeItem(container, tab, depth, getLinkedDocumentMeta(tab))) renderedDocuments++; }); }; - appendVirtualNode(rootNode, rootGroup, 1, []); - tree.appendChild(rootGroup); + appendVirtualNode(rootNode, rootGroup, 2, []); + workspaceGroup.appendChild(rootGroup); }); return renderedDocuments; } @@ -6948,10 +6967,9 @@ document.addEventListener("DOMContentLoaded", async function () { }); } - renderedDocuments += renderLinkedLocationsTree(tree); - documentOrganization.workspaces.forEach(function(workspace) { const isSecretWorkspace = workspace.id === SECRET_WORKSPACE_ID; + if (isSecretWorkspace) renderedDocuments += renderLinkedLocationsTree(tree); const secretLocked = isSecretWorkspace && !isSecretWorkspaceUnlocked(); const workspaceDocuments = tabs.filter(function(tab) { return !isTemporaryDocument(tab) && !tab.sourcePath && tab.workspaceId === workspace.id; @@ -6985,6 +7003,7 @@ document.addEventListener("DOMContentLoaded", async function () { ? (secretLocked ? 'lucide-shield' : 'lucide-shield-check') : (expanded ? 'lucide-folder-open' : 'lucide-folder'), depth: 0, + currentLocation: tabs.some(function(tab) { return tab.id === activeTabId && !tab.sourcePath && tab.workspaceId === workspace.id; }), expanded: expanded, meta: secretLocked ? 'Locked' : String(workspaceDocuments.length), locked: secretLocked, @@ -7264,11 +7283,38 @@ document.addEventListener("DOMContentLoaded", async function () { const unlockedWorkspaceIds = new Set(documentOrganization.workspaces.filter(function(workspace) { return workspace.id !== SECRET_WORKSPACE_ID || isSecretWorkspaceUnlocked(); }).map(function(workspace) { return workspace.id; })); - return documentOrganization.workspaces.filter(function(workspace) { + const items = documentOrganization.workspaces.filter(function(workspace) { return unlockedWorkspaceIds.has(workspace.id); }).concat(documentOrganization.folders.filter(function(folder) { return unlockedWorkspaceIds.has(folder.workspaceId); })); + if (typeof Neutralino !== 'undefined') { + items.push({ + get expanded() { return documentOrganization.ui.linkedWorkspaceExpanded !== false; }, + set expanded(value) { documentOrganization.ui.linkedWorkspaceExpanded = value; } + }); + (documentOrganization.linkedLocations || []).forEach(function(location) { + items.push(location); + const folderPaths = new Set(location.collapsedFolderPaths || []); + tabs.filter(function(tab) { return tab.sourcePath && tab.linkedRootId === location.id; }).forEach(function(tab) { + const parts = normalizeLinkedRelativePath(tab.sourceRelativePath).split('/').filter(Boolean); + parts.pop(); + parts.forEach(function(_, index) { folderPaths.add(parts.slice(0, index + 1).join('/')); }); + }); + folderPaths.forEach(function(folderPath) { + items.push({ + get expanded() { return !(location.collapsedFolderPaths || []).includes(folderPath); }, + set expanded(value) { + const collapsed = new Set(location.collapsedFolderPaths || []); + if (value) collapsed.delete(folderPath); + else collapsed.add(folderPath); + location.collapsedFolderPaths = Array.from(collapsed); + } + }); + }); + }); + } + return items; } function updateTreeExpansionButton(button, willExpand, label, enabled) { diff --git a/styles.css b/styles.css index 5b4fd3a0..8536ce39 100644 --- a/styles.css +++ b/styles.css @@ -592,7 +592,6 @@ body { background: color-mix(in srgb, var(--accent-color) 7%, transparent); } -.document-tree-row[data-tree-type="document"].is-selected > .document-tree-main > i:first-child, .document-tree-row[data-tree-type="document"].is-active > .document-tree-main > i:first-child { color: var(--accent-color); } @@ -727,7 +726,7 @@ body { text-align: center; } -.document-tree-main > .lucide-folder-open { +.document-tree-row.is-current-location > .document-tree-main > i:first-child { color: var(--accent-color); } @@ -752,10 +751,6 @@ body { font-size: var(--ui-font-xs); } -.document-tree-row.is-linked-document .document-tree-main > .lucide-link-2 { - color: var(--accent-color); -} - .document-tree-row.is-source-missing .document-tree-meta, .document-tree-row.is-source-missing .document-tree-main > i { color: var(--color-danger-fg); From 5bd34a96089f475867cb262abadc1afd09c838f8 Mon Sep 17 00:00:00 2001 From: Ioannis Kechagias Date: Sat, 3 Oct 2026 16:50:26 +0300 Subject: [PATCH 05/20] fix(desktop): preserve Welcome document on initial file launch --- desktop-app/resources/js/script.js | 19 +++++++++++++------ script.js | 19 +++++++++++++------ 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index a78c53d0..0a654993 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -13442,6 +13442,19 @@ document.addEventListener("DOMContentLoaded", async function () { const hadExistingWorkspace = tabs.length > 0; activeTabId = loadActiveTabId(); + // Seed the default Vault document before opening an initial linked file. + // File-association launches must not skip the Welcome on a fresh workspace. + if (!hadExistingWorkspace) { + const tab = createTab(sampleMarkdown, 'Welcome to Markdown', 'split', { + workspaceId: DEFAULT_WORKSPACE_ID, + folderId: null + }); + tabs.push(tab); + activeTabId = tab.id; + saveTabsToStorage(tabs); + saveActiveTabId(activeTabId); + } + // Check if Neutralino passed an initial file via command line (early load) if (window.NL_INITIAL_FILE_CONTENT) { const initialFile = window.NL_INITIAL_FILE_CONTENT; @@ -13452,12 +13465,6 @@ document.addEventListener("DOMContentLoaded", async function () { { activate: false } ); delete window.NL_INITIAL_FILE_CONTENT; - } else if (tabs.length === 0) { - const tab = createTab(sampleMarkdown, 'Welcome to Markdown'); - tabs.push(tab); - activeTabId = tab.id; - saveTabsToStorage(tabs); - saveActiveTabId(activeTabId); } else if (!tabs.find(function(t) { return t.id === activeTabId && isTabOpen(t) && !t.storageCorruption; })) { const firstOpenTab = getOpenTabs().find(function(tab) { return !tab.storageCorruption; }); activeTabId = firstOpenTab ? firstOpenTab.id : null; diff --git a/script.js b/script.js index a78c53d0..0a654993 100644 --- a/script.js +++ b/script.js @@ -13442,6 +13442,19 @@ document.addEventListener("DOMContentLoaded", async function () { const hadExistingWorkspace = tabs.length > 0; activeTabId = loadActiveTabId(); + // Seed the default Vault document before opening an initial linked file. + // File-association launches must not skip the Welcome on a fresh workspace. + if (!hadExistingWorkspace) { + const tab = createTab(sampleMarkdown, 'Welcome to Markdown', 'split', { + workspaceId: DEFAULT_WORKSPACE_ID, + folderId: null + }); + tabs.push(tab); + activeTabId = tab.id; + saveTabsToStorage(tabs); + saveActiveTabId(activeTabId); + } + // Check if Neutralino passed an initial file via command line (early load) if (window.NL_INITIAL_FILE_CONTENT) { const initialFile = window.NL_INITIAL_FILE_CONTENT; @@ -13452,12 +13465,6 @@ document.addEventListener("DOMContentLoaded", async function () { { activate: false } ); delete window.NL_INITIAL_FILE_CONTENT; - } else if (tabs.length === 0) { - const tab = createTab(sampleMarkdown, 'Welcome to Markdown'); - tabs.push(tab); - activeTabId = tab.id; - saveTabsToStorage(tabs); - saveActiveTabId(activeTabId); } else if (!tabs.find(function(t) { return t.id === activeTabId && isTabOpen(t) && !t.storageCorruption; })) { const firstOpenTab = getOpenTabs().find(function(tab) { return !tab.storageCorruption; }); activeTabId = firstOpenTab ? firstOpenTab.id : null; From b74bec0fa5a29900cffa07764e7769843ffcbdea Mon Sep 17 00:00:00 2001 From: Ioannis Kechagias Date: Sat, 3 Oct 2026 16:50:26 +0300 Subject: [PATCH 06/20] fix(storage): isolate linked recovery drafts from the normal Vault --- desktop-app/resources/js/workspace-storage.js | 46 +++++++++++++++---- workspace-storage.js | 46 +++++++++++++++---- 2 files changed, 74 insertions(+), 18 deletions(-) diff --git a/desktop-app/resources/js/workspace-storage.js b/desktop-app/resources/js/workspace-storage.js index e6b40ea7..566b5b38 100644 --- a/desktop-app/resources/js/workspace-storage.js +++ b/desktop-app/resources/js/workspace-storage.js @@ -353,6 +353,7 @@ if (this.desktop) await this._initDesktop(); else await this._initBrowser(); await this._migrateLegacyNormalDocuments(); + if (this.desktop) await this._migrateLinkedRecoveryDocuments(); this.ready = true; try { await this.purgeExpiredTrash(); @@ -522,6 +523,7 @@ const journalPath = await this._pathJoin(internalPath, 'journal'); await this._ensureDirectory(vaultPath); await this._ensureDirectory(workspacePath); + await this._ensureDirectory(await this._pathJoin(vaultPath, 'Linked Workspace')); await this._ensureDirectory(await this._pathJoin(vaultPath, 'Secret Workspace')); await this._ensureDirectory(secretPath); await this._ensureDirectory(internalPath); @@ -577,6 +579,25 @@ } } + async _migrateLinkedRecoveryDocuments() { + const drafts = this.vaultIndex.documents.filter(function(item) { + return item.sourcePath && !String(item.vaultRelativePath || '').startsWith('Linked Workspace/'); + }).map(function(item) { + return Object.assign({}, item, { + contentLoaded: false, + _storageRevision: normalizedStorageRevision(item.storageRevision) + }); + }); + // Validate all existing bodies before moving any file. Missing recovery + // content must stop migration rather than creating an empty replacement. + for (const draft of drafts) await this.loadDocumentContent(draft.id); + if (drafts.length) { + await this.saveDocuments(drafts, this.vaultOrganization, { + changedIds: drafts.map(function(item) { return item.id; }) + }); + } + } + async _rebuildDesktopIndex(workspacePath) { const organization = this.vaultOrganization && typeof this.vaultOrganization === 'object' ? cloneJson(this.vaultOrganization, null) @@ -606,7 +627,7 @@ return folder; }; - const walk = async (currentPath, relativeSegments, parentFolderId) => { + const walk = async (currentPath, relativeSegments, parentFolderId, storageRoot) => { let entries = []; try { entries = await Neutralino.filesystem.readDirectory(currentPath); @@ -620,8 +641,8 @@ if (!entry || !entry.entry) continue; const entryPath = await this._pathJoin(currentPath, entry.entry); if (entry.type === 'DIRECTORY') { - const folder = findOrCreateFolder(entry.entry, parentFolderId); - await walk(entryPath, relativeSegments.concat(entry.entry), folder.id); + const folder = storageRoot === 'Workspace' ? findOrCreateFolder(entry.entry, parentFolderId) : null; + await walk(entryPath, relativeSegments.concat(entry.entry), folder ? folder.id : null, storageRoot); continue; } if (entry.type !== 'FILE' || !/\.md$/i.test(entry.entry)) continue; @@ -646,13 +667,17 @@ lastOpenedAt: timestamp, lastEditedAt: timestamp, contentSize: Number(stats && stats.size) || 0, - vaultRelativePath: normalizePathSeparators(['Workspace'].concat(relativeSegments, entry.entry).join('/')), + vaultRelativePath: normalizePathSeparators([storageRoot].concat(relativeSegments, entry.entry).join('/')), + // A lost index cannot safely tell us the original source path. + // Preserve these bodies as recovery documents, not invented links. + kind: storageRoot === 'Linked Workspace' ? 'linked-recovery' : undefined, contentLoaded: false }); } }; - await walk(workspacePath, [], null); + await walk(workspacePath, [], null, 'Workspace'); + await walk(await this._pathJoin(this.vaultPath, 'Linked Workspace'), [], null, 'Linked Workspace'); if (documents.length) documents[0].isOpen = true; this.vaultOrganization = organization; return { @@ -1096,7 +1121,8 @@ } async _desktopDocumentRelativePath(tab, organization) { - const segments = ['Workspace'].concat(await this._desktopFolderSegments(tab, organization)); + const linked = Boolean(tab.sourcePath) || tab.kind === 'linked-recovery'; + const segments = linked ? ['Linked Workspace'] : ['Workspace'].concat(await this._desktopFolderSegments(tab, organization)); requireDocumentId(tab && tab.id); const suffix = await stableDocumentIdSuffix(tab.id); segments.push(sanitizePathSegment(tab.title, 'Untitled') + '--' + suffix + '.md'); @@ -1104,10 +1130,10 @@ } async _backupDocumentRelativePath(tab, organization) { - const storedPath = normalizePathSeparators(tab && (tab.vaultRelativePath || tab._vaultRelativePath)); + const storedPath = normalizePathSeparators(tab && (tab._vaultRelativePath || tab.vaultRelativePath)); if ( storedPath && - storedPath.startsWith('Workspace/') && + (storedPath.startsWith('Workspace/') || storedPath.startsWith('Linked Workspace/')) && !storedPath.split('/').some(function(segment) { return segment === '.' || segment === '..'; }) ) { return storedPath; @@ -1291,7 +1317,8 @@ metadata.contentSize = typeof tab.content === 'string' ? new TextEncoder().encode(tab.content).byteLength : Number(existing && existing.contentSize) || 0; - if (existing) Object.assign(existing, metadata); + // Conversion removes source fields: merging would retain them on restart. + if (existing) this.vaultIndex.documents[this.vaultIndex.documents.indexOf(existing)] = metadata; else this.vaultIndex.documents.push(metadata); tab._vaultRelativePath = relativePath; tab._pendingStorageRevision = metadata.storageRevision; @@ -2842,6 +2869,7 @@ } const ownedPaths = [ await this._pathJoin(this.vaultPath, 'Workspace'), + await this._pathJoin(this.vaultPath, 'Linked Workspace'), await this._pathJoin(this.vaultPath, 'Secret Workspace'), await this._pathJoin(this.vaultPath, INTERNAL_DIR) ]; diff --git a/workspace-storage.js b/workspace-storage.js index e6b40ea7..566b5b38 100644 --- a/workspace-storage.js +++ b/workspace-storage.js @@ -353,6 +353,7 @@ if (this.desktop) await this._initDesktop(); else await this._initBrowser(); await this._migrateLegacyNormalDocuments(); + if (this.desktop) await this._migrateLinkedRecoveryDocuments(); this.ready = true; try { await this.purgeExpiredTrash(); @@ -522,6 +523,7 @@ const journalPath = await this._pathJoin(internalPath, 'journal'); await this._ensureDirectory(vaultPath); await this._ensureDirectory(workspacePath); + await this._ensureDirectory(await this._pathJoin(vaultPath, 'Linked Workspace')); await this._ensureDirectory(await this._pathJoin(vaultPath, 'Secret Workspace')); await this._ensureDirectory(secretPath); await this._ensureDirectory(internalPath); @@ -577,6 +579,25 @@ } } + async _migrateLinkedRecoveryDocuments() { + const drafts = this.vaultIndex.documents.filter(function(item) { + return item.sourcePath && !String(item.vaultRelativePath || '').startsWith('Linked Workspace/'); + }).map(function(item) { + return Object.assign({}, item, { + contentLoaded: false, + _storageRevision: normalizedStorageRevision(item.storageRevision) + }); + }); + // Validate all existing bodies before moving any file. Missing recovery + // content must stop migration rather than creating an empty replacement. + for (const draft of drafts) await this.loadDocumentContent(draft.id); + if (drafts.length) { + await this.saveDocuments(drafts, this.vaultOrganization, { + changedIds: drafts.map(function(item) { return item.id; }) + }); + } + } + async _rebuildDesktopIndex(workspacePath) { const organization = this.vaultOrganization && typeof this.vaultOrganization === 'object' ? cloneJson(this.vaultOrganization, null) @@ -606,7 +627,7 @@ return folder; }; - const walk = async (currentPath, relativeSegments, parentFolderId) => { + const walk = async (currentPath, relativeSegments, parentFolderId, storageRoot) => { let entries = []; try { entries = await Neutralino.filesystem.readDirectory(currentPath); @@ -620,8 +641,8 @@ if (!entry || !entry.entry) continue; const entryPath = await this._pathJoin(currentPath, entry.entry); if (entry.type === 'DIRECTORY') { - const folder = findOrCreateFolder(entry.entry, parentFolderId); - await walk(entryPath, relativeSegments.concat(entry.entry), folder.id); + const folder = storageRoot === 'Workspace' ? findOrCreateFolder(entry.entry, parentFolderId) : null; + await walk(entryPath, relativeSegments.concat(entry.entry), folder ? folder.id : null, storageRoot); continue; } if (entry.type !== 'FILE' || !/\.md$/i.test(entry.entry)) continue; @@ -646,13 +667,17 @@ lastOpenedAt: timestamp, lastEditedAt: timestamp, contentSize: Number(stats && stats.size) || 0, - vaultRelativePath: normalizePathSeparators(['Workspace'].concat(relativeSegments, entry.entry).join('/')), + vaultRelativePath: normalizePathSeparators([storageRoot].concat(relativeSegments, entry.entry).join('/')), + // A lost index cannot safely tell us the original source path. + // Preserve these bodies as recovery documents, not invented links. + kind: storageRoot === 'Linked Workspace' ? 'linked-recovery' : undefined, contentLoaded: false }); } }; - await walk(workspacePath, [], null); + await walk(workspacePath, [], null, 'Workspace'); + await walk(await this._pathJoin(this.vaultPath, 'Linked Workspace'), [], null, 'Linked Workspace'); if (documents.length) documents[0].isOpen = true; this.vaultOrganization = organization; return { @@ -1096,7 +1121,8 @@ } async _desktopDocumentRelativePath(tab, organization) { - const segments = ['Workspace'].concat(await this._desktopFolderSegments(tab, organization)); + const linked = Boolean(tab.sourcePath) || tab.kind === 'linked-recovery'; + const segments = linked ? ['Linked Workspace'] : ['Workspace'].concat(await this._desktopFolderSegments(tab, organization)); requireDocumentId(tab && tab.id); const suffix = await stableDocumentIdSuffix(tab.id); segments.push(sanitizePathSegment(tab.title, 'Untitled') + '--' + suffix + '.md'); @@ -1104,10 +1130,10 @@ } async _backupDocumentRelativePath(tab, organization) { - const storedPath = normalizePathSeparators(tab && (tab.vaultRelativePath || tab._vaultRelativePath)); + const storedPath = normalizePathSeparators(tab && (tab._vaultRelativePath || tab.vaultRelativePath)); if ( storedPath && - storedPath.startsWith('Workspace/') && + (storedPath.startsWith('Workspace/') || storedPath.startsWith('Linked Workspace/')) && !storedPath.split('/').some(function(segment) { return segment === '.' || segment === '..'; }) ) { return storedPath; @@ -1291,7 +1317,8 @@ metadata.contentSize = typeof tab.content === 'string' ? new TextEncoder().encode(tab.content).byteLength : Number(existing && existing.contentSize) || 0; - if (existing) Object.assign(existing, metadata); + // Conversion removes source fields: merging would retain them on restart. + if (existing) this.vaultIndex.documents[this.vaultIndex.documents.indexOf(existing)] = metadata; else this.vaultIndex.documents.push(metadata); tab._vaultRelativePath = relativePath; tab._pendingStorageRevision = metadata.storageRevision; @@ -2842,6 +2869,7 @@ } const ownedPaths = [ await this._pathJoin(this.vaultPath, 'Workspace'), + await this._pathJoin(this.vaultPath, 'Linked Workspace'), await this._pathJoin(this.vaultPath, 'Secret Workspace'), await this._pathJoin(this.vaultPath, INTERNAL_DIR) ]; From c7f72038ce1a087305353eb7a2cf2552da6acb18 Mon Sep 17 00:00:00 2001 From: Ioannis Kechagias Date: Sat, 3 Oct 2026 16:50:27 +0300 Subject: [PATCH 07/20] fix(desktop): keep converted Vault copies independent from linked originals --- desktop-app/resources/js/script.js | 8 ++------ script.js | 8 ++------ 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index 0a654993..35b1845c 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -11390,12 +11390,8 @@ document.addEventListener("DOMContentLoaded", async function () { let tab = findTabBySourcePath(normalizedPath); let contentRefreshed = false; - // Adopt one matching pre-link document created by the earlier launcher build. - if (!tab && normalizedPath) { - tab = tabs.find(function(candidate) { - return !candidate.sourcePath && candidate.title === title && candidate.content === incomingContent; - }) || null; - } + // Only a matching source path identifies a linked document. A Vault copy + // remains independent even when its title and content match the original. if (tab) { await ensureTabContent(tab); diff --git a/script.js b/script.js index 0a654993..35b1845c 100644 --- a/script.js +++ b/script.js @@ -11390,12 +11390,8 @@ document.addEventListener("DOMContentLoaded", async function () { let tab = findTabBySourcePath(normalizedPath); let contentRefreshed = false; - // Adopt one matching pre-link document created by the earlier launcher build. - if (!tab && normalizedPath) { - tab = tabs.find(function(candidate) { - return !candidate.sourcePath && candidate.title === title && candidate.content === incomingContent; - }) || null; - } + // Only a matching source path identifies a linked document. A Vault copy + // remains independent even when its title and content match the original. if (tab) { await ensureTabContent(tab); From ce6a5ab9c55bf296eba8001d1f2f61e9957a6f5c Mon Sep 17 00:00:00 2001 From: Ioannis Kechagias Date: Sat, 3 Oct 2026 16:50:27 +0300 Subject: [PATCH 08/20] fix(desktop): clarify linked removal actions and simplify context menus --- desktop-app/resources/js/script.js | 5 ++--- script.js | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index 35b1845c..28a4acd7 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -5900,10 +5900,9 @@ document.addEventListener("DOMContentLoaded", async function () { { id: 'open', icon: 'lucide-file-symlink', label: 'Open', run: function() { openSidebarDocument(tab.id); } }, { id: 'reload-source', icon: 'lucide-refresh-cw', label: 'Reload from disk', run: function() { void reloadLinkedDocument(tab.id, { force: true }); } }, { id: 'reveal-source', icon: 'lucide-folder-symlink', label: 'Open containing folder', run: function() { void revealLinkedSource(tab); } }, - { id: 'copy-source-path', icon: 'lucide-copy', label: 'Copy source path', run: function() { void copyTextToClipboard(tab.sourcePath); } }, { separator: true }, { id: 'convert-vault', icon: 'lucide-files', label: 'Convert to Workspace copy', run: function() { void convertLinkedDocumentToVault(tab.id); } }, - { id: 'remove-link', icon: 'lucide-x', label: 'Remove link from Workspace', run: function() { void removeLinkedDocument(tab.id); } } + { id: 'remove-link', icon: 'lucide-x', label: 'Remove linked file', danger: true, run: function() { void removeLinkedDocument(tab.id); } } ]; return linkedActions; } @@ -6818,7 +6817,7 @@ document.addEventListener("DOMContentLoaded", async function () { return [ { id: 'rescan', icon: 'lucide-refresh-cw', label: 'Rescan folder', run: function() { void reconcileLinkedLocation(location.id, { notify: true }); } }, { id: 'reveal', icon: 'lucide-folder-symlink', label: 'Open folder', run: function() { void Neutralino.os.open(location.path); } }, - { id: 'unlink', icon: 'lucide-x', label: 'Remove linked folder', run: function() { void removeLinkedLocation(location.id); } } + { id: 'unlink', icon: 'lucide-x', label: 'Remove linked folder', danger: true, run: function() { void removeLinkedLocation(location.id); } } ]; } diff --git a/script.js b/script.js index 35b1845c..28a4acd7 100644 --- a/script.js +++ b/script.js @@ -5900,10 +5900,9 @@ document.addEventListener("DOMContentLoaded", async function () { { id: 'open', icon: 'lucide-file-symlink', label: 'Open', run: function() { openSidebarDocument(tab.id); } }, { id: 'reload-source', icon: 'lucide-refresh-cw', label: 'Reload from disk', run: function() { void reloadLinkedDocument(tab.id, { force: true }); } }, { id: 'reveal-source', icon: 'lucide-folder-symlink', label: 'Open containing folder', run: function() { void revealLinkedSource(tab); } }, - { id: 'copy-source-path', icon: 'lucide-copy', label: 'Copy source path', run: function() { void copyTextToClipboard(tab.sourcePath); } }, { separator: true }, { id: 'convert-vault', icon: 'lucide-files', label: 'Convert to Workspace copy', run: function() { void convertLinkedDocumentToVault(tab.id); } }, - { id: 'remove-link', icon: 'lucide-x', label: 'Remove link from Workspace', run: function() { void removeLinkedDocument(tab.id); } } + { id: 'remove-link', icon: 'lucide-x', label: 'Remove linked file', danger: true, run: function() { void removeLinkedDocument(tab.id); } } ]; return linkedActions; } @@ -6818,7 +6817,7 @@ document.addEventListener("DOMContentLoaded", async function () { return [ { id: 'rescan', icon: 'lucide-refresh-cw', label: 'Rescan folder', run: function() { void reconcileLinkedLocation(location.id, { notify: true }); } }, { id: 'reveal', icon: 'lucide-folder-symlink', label: 'Open folder', run: function() { void Neutralino.os.open(location.path); } }, - { id: 'unlink', icon: 'lucide-x', label: 'Remove linked folder', run: function() { void removeLinkedLocation(location.id); } } + { id: 'unlink', icon: 'lucide-x', label: 'Remove linked folder', danger: true, run: function() { void removeLinkedLocation(location.id); } } ]; } From 7fbee44e0b3ec841113a927a0d25de604d3ef550 Mon Sep 17 00:00:00 2001 From: Ioannis Kechagias Date: Sat, 3 Oct 2026 16:50:28 +0300 Subject: [PATCH 09/20] fix(ui): align desktop toast status icons with existing notifications --- desktop-app/resources/js/script.js | 12 ++++++++---- desktop-app/resources/styles.css | 3 ++- script.js | 12 ++++++++---- styles.css | 3 ++- 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index 28a4acd7..9b9f53d6 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -6264,8 +6264,7 @@ document.addEventListener("DOMContentLoaded", async function () { if (cut) replaceDocumentSurfaceSelection(context, ''); showAppToast(cut ? 'Selection cut to clipboard.' : 'Selection copied to clipboard.', { tone: 'success', - title: cut ? 'Cut' : 'Copied', - icon: cut ? 'lucide-square-pen' : 'lucide-copy' + title: cut ? 'Cut' : 'Copied' }); } catch (error) { showAppToast('Clipboard access failed: ' + error.message, { tone: 'error', title: 'Clipboard unavailable' }); @@ -11687,7 +11686,11 @@ document.addEventListener("DOMContentLoaded", async function () { renderTabBar(tabs, activeTabId); renderDocumentSidebar(); await refreshLinkedSourceMonitoring(); - showAppToast('A separate Vault copy was created. The original file was left unchanged at ' + sourcePath, { tone: 'success', title: 'Converted to Workspace copy' }); + showAppToast('A separate Vault copy was created. The original file was left unchanged at ' + sourcePath, { + tone: 'success', + icon: 'lucide-check', + title: 'Converted to Workspace copy' + }); } async function removeLinkedDocument(tabId) { @@ -15586,7 +15589,7 @@ ${selector} .arrowheadPath { icon.className = settings.iconClass || ('lucide ' + (settings.icon || ({ error: 'lucide-circle-alert', warning: 'lucide-triangle-alert', - success: 'lucide-circle-check', + success: 'lucide-check', info: 'lucide-info' }[tone] || 'lucide-info'))); iconShell.appendChild(icon); @@ -24800,6 +24803,7 @@ ${selector} .arrowheadPath { renderDocumentSidebar(); showAppToast('Saved to ' + tab.sourcePath, { tone: 'success', + icon: 'lucide-check', title: 'Original file updated' }); return true; diff --git a/desktop-app/resources/styles.css b/desktop-app/resources/styles.css index 8536ce39..15fecfa0 100644 --- a/desktop-app/resources/styles.css +++ b/desktop-app/resources/styles.css @@ -1335,7 +1335,8 @@ body.document-sidebar-collapsed .document-sidebar { color: var(--accent-color); } -.app-toast-icon-shell > .bi { +.app-toast-icon-shell > .bi, +.app-toast-icon-shell > .lucide { font-size: var(--ui-icon-size-lg); line-height: 1; } diff --git a/script.js b/script.js index 28a4acd7..9b9f53d6 100644 --- a/script.js +++ b/script.js @@ -6264,8 +6264,7 @@ document.addEventListener("DOMContentLoaded", async function () { if (cut) replaceDocumentSurfaceSelection(context, ''); showAppToast(cut ? 'Selection cut to clipboard.' : 'Selection copied to clipboard.', { tone: 'success', - title: cut ? 'Cut' : 'Copied', - icon: cut ? 'lucide-square-pen' : 'lucide-copy' + title: cut ? 'Cut' : 'Copied' }); } catch (error) { showAppToast('Clipboard access failed: ' + error.message, { tone: 'error', title: 'Clipboard unavailable' }); @@ -11687,7 +11686,11 @@ document.addEventListener("DOMContentLoaded", async function () { renderTabBar(tabs, activeTabId); renderDocumentSidebar(); await refreshLinkedSourceMonitoring(); - showAppToast('A separate Vault copy was created. The original file was left unchanged at ' + sourcePath, { tone: 'success', title: 'Converted to Workspace copy' }); + showAppToast('A separate Vault copy was created. The original file was left unchanged at ' + sourcePath, { + tone: 'success', + icon: 'lucide-check', + title: 'Converted to Workspace copy' + }); } async function removeLinkedDocument(tabId) { @@ -15586,7 +15589,7 @@ ${selector} .arrowheadPath { icon.className = settings.iconClass || ('lucide ' + (settings.icon || ({ error: 'lucide-circle-alert', warning: 'lucide-triangle-alert', - success: 'lucide-circle-check', + success: 'lucide-check', info: 'lucide-info' }[tone] || 'lucide-info'))); iconShell.appendChild(icon); @@ -24800,6 +24803,7 @@ ${selector} .arrowheadPath { renderDocumentSidebar(); showAppToast('Saved to ' + tab.sourcePath, { tone: 'success', + icon: 'lucide-check', title: 'Original file updated' }); return true; diff --git a/styles.css b/styles.css index 8536ce39..15fecfa0 100644 --- a/styles.css +++ b/styles.css @@ -1335,7 +1335,8 @@ body.document-sidebar-collapsed .document-sidebar { color: var(--accent-color); } -.app-toast-icon-shell > .bi { +.app-toast-icon-shell > .bi, +.app-toast-icon-shell > .lucide { font-size: var(--ui-icon-size-lg); line-height: 1; } From b8b2ee302096b45e47b5cc09fa630a059e15d829 Mon Sep 17 00:00:00 2001 From: Ioannis Kechagias Date: Sat, 3 Oct 2026 16:50:28 +0300 Subject: [PATCH 10/20] fix(desktop): restore excluded links on explicit folder rescans --- desktop-app/resources/js/script.js | 7 +++++-- script.js | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index 9b9f53d6..7157b021 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -6814,7 +6814,7 @@ document.addEventListener("DOMContentLoaded", async function () { function getLinkedLocationMenuActions(location) { return [ - { id: 'rescan', icon: 'lucide-refresh-cw', label: 'Rescan folder', run: function() { void reconcileLinkedLocation(location.id, { notify: true }); } }, + { id: 'rescan', icon: 'lucide-refresh-cw', label: 'Rescan folder', run: function() { void reconcileLinkedLocation(location.id, { notify: true, restoreExcluded: true }); } }, { id: 'reveal', icon: 'lucide-folder-symlink', label: 'Open folder', run: function() { void Neutralino.os.open(location.path); } }, { id: 'unlink', icon: 'lucide-x', label: 'Remove linked folder', danger: true, run: function() { void removeLinkedLocation(location.id); } } ]; @@ -11561,6 +11561,9 @@ document.addEventListener("DOMContentLoaded", async function () { if (settings.notify) showAppToast('The linked folder is unavailable: ' + location.path, { tone: 'error', title: 'Folder scan failed' }); return 0; } + // Explicit rescans/re-linking restore removed links; background refreshes + // continue respecting exclusions. Only reset after a successful folder scan. + if (settings.restoreExcluded) location.excludedPaths = []; const excluded = new Set((location.excludedPaths || []).map(normalizeLinkedRelativePath)); const discoveredPaths = new Set(); let added = 0; @@ -11611,7 +11614,7 @@ document.addEventListener("DOMContentLoaded", async function () { documentOrganization.linkedLocations.push(location); saveDocumentOrganization(); } - await reconcileLinkedLocation(location.id, { notify: !(options && options.silent) }); + await reconcileLinkedLocation(location.id, { notify: !(options && options.silent), restoreExcluded: true }); await refreshLinkedSourceMonitoring(); return location; } diff --git a/script.js b/script.js index 9b9f53d6..7157b021 100644 --- a/script.js +++ b/script.js @@ -6814,7 +6814,7 @@ document.addEventListener("DOMContentLoaded", async function () { function getLinkedLocationMenuActions(location) { return [ - { id: 'rescan', icon: 'lucide-refresh-cw', label: 'Rescan folder', run: function() { void reconcileLinkedLocation(location.id, { notify: true }); } }, + { id: 'rescan', icon: 'lucide-refresh-cw', label: 'Rescan folder', run: function() { void reconcileLinkedLocation(location.id, { notify: true, restoreExcluded: true }); } }, { id: 'reveal', icon: 'lucide-folder-symlink', label: 'Open folder', run: function() { void Neutralino.os.open(location.path); } }, { id: 'unlink', icon: 'lucide-x', label: 'Remove linked folder', danger: true, run: function() { void removeLinkedLocation(location.id); } } ]; @@ -11561,6 +11561,9 @@ document.addEventListener("DOMContentLoaded", async function () { if (settings.notify) showAppToast('The linked folder is unavailable: ' + location.path, { tone: 'error', title: 'Folder scan failed' }); return 0; } + // Explicit rescans/re-linking restore removed links; background refreshes + // continue respecting exclusions. Only reset after a successful folder scan. + if (settings.restoreExcluded) location.excludedPaths = []; const excluded = new Set((location.excludedPaths || []).map(normalizeLinkedRelativePath)); const discoveredPaths = new Set(); let added = 0; @@ -11611,7 +11614,7 @@ document.addEventListener("DOMContentLoaded", async function () { documentOrganization.linkedLocations.push(location); saveDocumentOrganization(); } - await reconcileLinkedLocation(location.id, { notify: !(options && options.silent) }); + await reconcileLinkedLocation(location.id, { notify: !(options && options.silent), restoreExcluded: true }); await refreshLinkedSourceMonitoring(); return location; } From 4756b86021dfa689a9ea26a7995d26f603f050dd Mon Sep 17 00:00:00 2001 From: Ioannis Kechagias Date: Sat, 3 Oct 2026 16:50:29 +0300 Subject: [PATCH 11/20] fix(sidebar): show linked source paths only on hover --- desktop-app/resources/js/script.js | 6 ++++-- script.js | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index 7157b021..60bc53b2 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -6641,7 +6641,7 @@ document.addEventListener("DOMContentLoaded", async function () { main.type = 'button'; main.className = 'document-tree-main'; main.setAttribute('tabindex', '-1'); - main.title = options.label; + main.title = options.hoverTitle || options.label; const icon = document.createElement('i'); icon.className = 'lucide ' + options.icon; icon.setAttribute('aria-hidden', 'true'); @@ -6733,6 +6733,7 @@ document.addEventListener("DOMContentLoaded", async function () { id: tab.id, documentId: tab.id, label: tab.title || 'Untitled', + hoverTitle: tab.sourcePath || tab.title || 'Untitled', ariaLabel: (tab.id === activeTabId ? 'Active document, ' : 'Document, ') + (tab.title || 'Untitled') + (tab.sourcePath ? ', linked file' : '') + (tab.favorite ? ', favorite' : ''), icon: tab.sourcePath ? 'lucide-file-symlink' : 'lucide-file-text', depth: depth, @@ -6801,7 +6802,8 @@ document.addEventListener("DOMContentLoaded", async function () { } let renderedDocuments = 0; documents.forEach(function(tab) { - if (appendDocumentTreeItem(tree, tab, 0, getDocumentLocationLabel(tab))) renderedDocuments++; + const meta = tab.sourcePath ? getLinkedDocumentMeta(tab) : getDocumentLocationLabel(tab); + if (appendDocumentTreeItem(tree, tab, 0, meta)) renderedDocuments++; }); return renderedDocuments; } diff --git a/script.js b/script.js index 7157b021..60bc53b2 100644 --- a/script.js +++ b/script.js @@ -6641,7 +6641,7 @@ document.addEventListener("DOMContentLoaded", async function () { main.type = 'button'; main.className = 'document-tree-main'; main.setAttribute('tabindex', '-1'); - main.title = options.label; + main.title = options.hoverTitle || options.label; const icon = document.createElement('i'); icon.className = 'lucide ' + options.icon; icon.setAttribute('aria-hidden', 'true'); @@ -6733,6 +6733,7 @@ document.addEventListener("DOMContentLoaded", async function () { id: tab.id, documentId: tab.id, label: tab.title || 'Untitled', + hoverTitle: tab.sourcePath || tab.title || 'Untitled', ariaLabel: (tab.id === activeTabId ? 'Active document, ' : 'Document, ') + (tab.title || 'Untitled') + (tab.sourcePath ? ', linked file' : '') + (tab.favorite ? ', favorite' : ''), icon: tab.sourcePath ? 'lucide-file-symlink' : 'lucide-file-text', depth: depth, @@ -6801,7 +6802,8 @@ document.addEventListener("DOMContentLoaded", async function () { } let renderedDocuments = 0; documents.forEach(function(tab) { - if (appendDocumentTreeItem(tree, tab, 0, getDocumentLocationLabel(tab))) renderedDocuments++; + const meta = tab.sourcePath ? getLinkedDocumentMeta(tab) : getDocumentLocationLabel(tab); + if (appendDocumentTreeItem(tree, tab, 0, meta)) renderedDocuments++; }); return renderedDocuments; } From d1c5d858cbd8bad541dcab6261107f7513ff6523 Mon Sep 17 00:00:00 2001 From: Ioannis Kechagias Date: Sat, 3 Oct 2026 16:50:30 +0300 Subject: [PATCH 12/20] test(desktop): cover linked workspace review fixes and storage migration --- tests/e2e/desktop-linked-file.spec.js | 168 +++++++++++++++- .../desktop-linked-recovery-storage.spec.js | 188 ++++++++++++++++++ tests/e2e/desktop-toast-icons.spec.js | 58 ++++++ tests/e2e/document-sidebar.spec.js | 5 +- tests/helpers/desktop-filesystem.js | 70 +++++++ tests/unit/linked-workspace-storage.test.mjs | 133 +++++++++++++ 6 files changed, 613 insertions(+), 9 deletions(-) create mode 100644 tests/e2e/desktop-linked-recovery-storage.spec.js create mode 100644 tests/e2e/desktop-toast-icons.spec.js create mode 100644 tests/helpers/desktop-filesystem.js create mode 100644 tests/unit/linked-workspace-storage.test.mjs diff --git a/tests/e2e/desktop-linked-file.spec.js b/tests/e2e/desktop-linked-file.spec.js index f1d902fc..d005d459 100644 --- a/tests/e2e/desktop-linked-file.spec.js +++ b/tests/e2e/desktop-linked-file.spec.js @@ -1,7 +1,70 @@ const { test, expect } = require('@playwright/test'); const { openApp, editorValue } = require('../helpers/app'); -test('deduplicates linked source paths and writes the original only on Ctrl+S', async ({ page }) => { +test('linked sidebar rows show source paths only on hover across All, Recent and Favs', async ({ page }) => { + await page.addInitScript(() => { + const records = ['healthy', 'missing', 'conflict'].map((title, index) => ({ + id: 'linked_' + title, title, content: '# ' + title, + sourcePath: 'C:/Original/' + title + '.md', + sourceMissing: title === 'missing', sourceConflict: title === 'conflict', + favorite: true, isOpen: true, workspaceId: 'workspace_default', + createdAt: index + 1, lastOpenedAt: index + 1, lastEditedAt: index + 1 + })); + localStorage.setItem('markdownViewerTabs', JSON.stringify(records)); + }); + await openApp(page); + await page.evaluate(async () => { + window.Neutralino = {}; + await window.NL_IMPORT_EXTERNAL_FILE('# healthy', 'healthy', 'C:/Original/healthy.md'); + }); + const tree = page.locator('#document-tree'); + if (!(await tree.isVisible())) await page.locator('#document-sidebar-open').click(); + for (const filter of ['all', 'recent', 'favorites']) { + await page.locator(`[data-document-filter="${filter}"]`).click(); + const row = title => tree.locator(`[data-document-id="linked_${title}"]`); + await expect(row('healthy')).toBeVisible(); + await expect(row('healthy').locator('.document-tree-meta')).toHaveCount(0); + await expect(row('healthy').locator('.lucide-file-symlink')).toHaveCount(1); + await expect(row('healthy')).toHaveAttribute('title', 'C:/Original/healthy.md'); + await expect(row('healthy').locator('.document-tree-main')).toHaveAttribute('title', 'C:/Original/healthy.md'); + await expect(row('healthy')).not.toContainText('C:/Original'); + await expect(row('missing').locator('.document-tree-meta')).toHaveText('Missing source'); + await expect(row('conflict').locator('.document-tree-meta')).toHaveText('Conflict'); + } + await page.locator('#document-sidebar-search').fill('C:/Original/healthy.md'); + await expect(tree.locator('[data-tree-type="document"]')).toHaveCount(1); +}); + +test('first file-association launch seeds Welcome in the Vault without replacing the linked document', async ({ page }) => { + await page.addInitScript(() => { + window.NL_INITIAL_FILE_CONTENT = { content: '# Initial original', name: 'initial', sourcePath: 'C:/Docs/initial.md' }; + }); + await openApp(page); + const welcome = page.locator('#document-tree [data-tree-type="document"]').filter({ hasText: 'Welcome to Markdown' }); + await expect(welcome).toHaveCount(1); + await expect(welcome.locator('.lucide-file-text')).toHaveCount(1); + await expect(welcome).not.toHaveClass(/is-linked-document/); + await expect(welcome).toHaveAttribute('data-tree-depth', '1'); + await expect.poll(() => editorValue(page)).toBe('# Initial original'); + await expect(page.locator('.tab-item.active .tab-title')).toHaveText('initial'); + await welcome.locator('.document-tree-main').click(); + await expect.poll(() => editorValue(page)).toContain('# Welcome to Markdown Viewer'); + await page.waitForTimeout(700); + await page.reload(); + await page.waitForFunction(() => document.documentElement.dataset.appReady === 'true'); + await expect(welcome).toHaveCount(1); + await expect.poll(() => editorValue(page)).toBe('# Initial original'); + await welcome.dispatchEvent('contextmenu', { clientX: 120, clientY: 120 }); + await page.locator('.document-menu-context.open').getByRole('menuitem', { name: 'Delete', exact: true }).click(); + await expect(welcome).toHaveCount(0); + await page.waitForTimeout(700); + await page.reload(); + await page.waitForFunction(() => document.documentElement.dataset.appReady === 'true'); + await expect(welcome).toHaveCount(0); + await expect.poll(() => editorValue(page)).toBe('# Initial original'); +}); + +test('deduplicates Windows source paths and writes the original only on Ctrl+S', async ({ page }) => { await openApp(page); await page.evaluate(async () => { await window.NL_IMPORT_EXTERNAL_FILE('# original', 'notes', 'C:\\Docs\\notes.md'); @@ -68,8 +131,7 @@ test('links a folder, discovers external additions, and converts a file to a cop }); await page.locator('#document-sidebar-open').click(); - await expect(page.locator('#document-tree')).toContainText('Linked Locations'); - await expect(page.locator('.document-tree-row[data-tree-type="document"]').filter({ hasText: 'one' })).toContainText('Linked'); + await expect(page.locator('#document-tree')).toContainText('Linked Workspace'); await page.evaluate(async () => { await window.NL_START_LINKED_MONITORING(); window.__folderEntries.push('two.md'); @@ -85,13 +147,104 @@ test('links a folder, discovers external additions, and converts a file to a cop expect(missingMenuIcons).toEqual([]); await page.getByRole('menuitem', { name: 'Convert to Workspace copy' }).click(); - await expect(linkedRow).not.toContainText('Linked'); + await expect(page.locator('#document-tree')).toContainText('Workspace (Vault)'); + await expect(linkedRow).not.toHaveClass(/is-linked-document/); await expect(linkedRow.locator('.lucide-file-text')).toHaveCount(1); await expect(page.locator('.document-tree-row[data-tree-type="linked-location"]').filter({ hasText: 'Notes' })).toContainText('1'); await expect.poll(() => page.evaluate(() => window.__fileContents['C:\\Notes\\one.md'])).toBe('# one'); await expect.poll(() => page.evaluate(() => window.__linkedWrites)).toEqual([]); }); +test('groups linked files under Workspaces and persists independent nested folder expansion', async ({ page }) => { + await openApp(page); + await page.evaluate(async () => { + const directories = { + 'C:/Notes': [{ entry: 'first', type: 'DIRECTORY' }, { entry: 'second', type: 'DIRECTORY' }], + 'C:/Notes/first': [{ entry: 'deep', type: 'DIRECTORY' }, { entry: 'one.md', type: 'FILE' }], + 'C:/Notes/first/deep': [{ entry: 'nested.md', type: 'FILE' }], + 'C:/Notes/second': [{ entry: 'two.md', type: 'FILE' }] + }; + window.Neutralino = { + filesystem: { + getStats: async path => ({ isDirectory: Boolean(directories[path.replace(/\\/g, '/')]), isFile: !directories[path.replace(/\\/g, '/')] }), + readDirectory: async path => directories[path.replace(/\\/g, '/')] || [], + getJoinedPath: async (...parts) => parts.join('/'), + readFile: async path => '# ' + path, + createWatcher: async () => 1, + removeWatcher: async () => 1 + }, + events: { on: async () => ({ success: true }) }, + storage: { setData: async () => ({ success: true }) } + }; + Neutralino.os = { showFolderDialog: async () => 'C:/Notes' }; + document.getElementById('sidebar-link-folder').click(); + await window.NL_IMPORT_EXTERNAL_FILE('# loose', 'loose', 'C:/loose.md'); + }); + const tree = page.locator('#document-tree'); + if (!(await tree.isVisible())) await page.locator('#document-sidebar-open').click(); + const roots = tree.locator('.document-tree-row[data-tree-depth="0"]'); + await expect(roots.locator('.document-tree-label')).toHaveText(['Workspace (Vault)', 'Linked Workspace', 'Secret Workspace']); + await expect(tree).not.toContainText('Linked Locations'); + await expect(tree.locator('[data-tree-type="linked-workspace"] > .document-tree-main > i')).toHaveClass('lucide lucide-link-2'); + await expect(tree.locator('[data-tree-type="linked-location"] > .document-tree-main > i')).toHaveClass('lucide lucide-folder-symlink'); + const symlinkMasks = await tree.locator('[data-tree-type="linked-virtual-folder"] > .document-tree-main > i').evaluateAll(icons => icons.map(icon => ({ className: icon.className, mask: getComputedStyle(icon).maskImage }))); + expect(symlinkMasks.length).toBeGreaterThan(0); + expect(symlinkMasks.every(icon => icon.className === 'lucide lucide-folder-symlink' && icon.mask !== 'none')).toBe(true); + const folder = name => tree.locator('[data-tree-type="linked-virtual-folder"]').filter({ has: page.locator('.document-tree-label', { hasText: new RegExp('^' + name + '$') }) }); + const documentRow = name => tree.locator('[data-tree-type="document"]').filter({ has: page.locator('.document-tree-label', { hasText: new RegExp('^' + name + '$') }) }); + await folder('deep').locator('.document-tree-toggle').click(); + await expect(folder('deep')).toHaveAttribute('aria-expanded', 'false'); + await expect(documentRow('nested')).toBeHidden(); + await expect(documentRow('one')).toBeVisible(); + await folder('first').locator('.document-tree-toggle').click(); + await expect(documentRow('one')).toBeHidden(); + await expect(documentRow('two')).toBeVisible(); + await folder('first').locator('.document-tree-main').click(); + await expect(documentRow('one')).toBeVisible(); + await expect(documentRow('nested')).toBeHidden(); + + await documentRow('one').locator('.document-tree-main').click(); + const colors = await tree.evaluate(element => { + const iconColor = row => getComputedStyle(row.querySelector('.document-tree-main > i')).color; + const rows = Array.from(element.querySelectorAll('[data-tree-type="document"]')); + return { + active: iconColor(rows.find(row => row.classList.contains('is-active'))), + inactive: rows.filter(row => !row.classList.contains('is-active')).map(iconColor), + folders: Array.from(element.querySelectorAll('[data-tree-type="linked-virtual-folder"]')).map(iconColor), + root: iconColor(element.querySelector('[data-tree-type="linked-location"]')), + gray: getComputedStyle(element.querySelector('.document-tree-meta')).color + }; + }); + expect(colors.active).not.toBe(colors.gray); + expect(colors.root).toBe(colors.active); + expect(colors.inactive.every(color => color === colors.gray)).toBe(true); + expect(colors.folders.every(color => color === colors.gray)).toBe(true); + + const linkedRoot = tree.locator('[data-tree-type="linked-workspace"]'); + await linkedRoot.locator('.document-tree-toggle').click(); + await expect(documentRow('loose')).toBeHidden(); + await page.waitForTimeout(700); + await page.reload(); + await page.waitForFunction(() => document.documentElement.dataset.appReady === 'true'); + await page.evaluate(async () => { + window.Neutralino = {}; + await window.NL_IMPORT_EXTERNAL_FILE('# loose', 'loose', 'C:/loose.md'); + }); + await expect(linkedRoot).toHaveAttribute('aria-expanded', 'false'); + await linkedRoot.locator('.document-tree-main').click(); + await expect(folder('deep')).toHaveAttribute('aria-expanded', 'false'); + await expect(documentRow('nested')).toBeHidden(); + await folder('deep').focus(); + await folder('deep').press('ArrowRight'); + await expect(documentRow('nested')).toBeVisible(); + await page.locator('#document-sidebar-collapse-all').click(); + await expect(linkedRoot).toHaveAttribute('aria-expanded', 'false'); + await expect(folder('deep')).toHaveAttribute('aria-expanded', 'false'); + await page.locator('#document-sidebar-collapse-all').click(); + await expect(linkedRoot).toHaveAttribute('aria-expanded', 'true'); + await expect(documentRow('nested')).toBeVisible(); +}); + test('removing an individual link leaves the source on disk', async ({ page }) => { await openApp(page); await page.evaluate(() => { @@ -119,7 +272,7 @@ test('removing an individual link leaves the source on disk', async ({ page }) = window.confirm = () => { throw new Error('browser confirm must not be used'); }; }); await linkedRow.dispatchEvent('contextmenu', { clientX: 120, clientY: 120 }); - await page.getByRole('menuitem', { name: 'Remove link from Workspace' }).click(); + await page.getByRole('menuitem', { name: 'Remove linked file' }).click(); await expect(linkedRow).toHaveCount(0); await expect.poll(() => page.evaluate(() => window.__original)).toBe('# original'); @@ -133,8 +286,9 @@ test('browser Workspace stays distinct and desktop link commands have separate i await expect(page.locator('#document-tree')).toContainText('Workspace'); await expect(page.locator('#document-tree')).not.toContainText('Workspace (Vault)'); await expect(page.locator('#sidebar-new-folder i')).toHaveClass(/lucide-folder-plus/); - await expect(page.locator('#sidebar-link-folder i')).toHaveClass(/lucide-folder-open/); - const icons = await page.locator('#sidebar-new-folder i, #sidebar-link-folder i').evaluateAll(nodes => nodes.map(node => ({ + await expect(page.locator('#sidebar-link-folder i')).toHaveClass(/lucide-folder-symlink/); + await expect(page.locator('#sidebar-link-files i')).toHaveClass(/lucide-file-symlink/); + const icons = await page.locator('#sidebar-new-folder i, #sidebar-link-folder i, #sidebar-link-files i').evaluateAll(nodes => nodes.map(node => ({ mask: getComputedStyle(node).maskImage, webkitMask: getComputedStyle(node).webkitMaskImage }))); diff --git a/tests/e2e/desktop-linked-recovery-storage.spec.js b/tests/e2e/desktop-linked-recovery-storage.spec.js new file mode 100644 index 00000000..6caab37c --- /dev/null +++ b/tests/e2e/desktop-linked-recovery-storage.spec.js @@ -0,0 +1,188 @@ +const { test, expect } = require('@playwright/test'); +const { openApp } = require('../helpers/app'); +const { installDesktopFilesystem } = require('../helpers/desktop-filesystem'); + +for (const action of ['rescan', 're-add']) { + test(`${action} restores excluded folder links without relinking a converted Vault copy`, async ({ page }) => { + await page.addInitScript('(' + installDesktopFilesystem.toString() + ')(window);'); + await openApp(page); + await page.evaluate(async () => { + await Neutralino.filesystem.createDirectory('C:/Original'); + window.__desktopFiles.set('C:/Original/notes.md', '# original'); + window.__linkedHandlers = {}; + Neutralino.events.on = async (name, handler) => { window.__linkedHandlers[name] = handler; }; + const readDirectory = Neutralino.filesystem.readDirectory; + window.__folderReads = 0; + Neutralino.filesystem.readDirectory = async path => { + if (path === 'C:/Original') window.__folderReads++; + return readDirectory(path); + }; + await window.NL_HANDLE_NATIVE_DROP(['C:/Original']); + await window.NL_START_LINKED_MONITORING(); + }); + const rows = page.locator('#document-tree [data-tree-type="document"]').filter({ hasText: 'notes' }); + const location = page.locator('#document-tree [data-tree-type="linked-location"]'); + const copyId = await rows.getAttribute('data-document-id'); + await rows.dispatchEvent('contextmenu', { clientX: 120, clientY: 120 }); + await page.getByRole('menuitem', { name: 'Convert to Workspace copy' }).click(); + await expect(rows).not.toHaveClass(/is-linked-document/); + const reads = await page.evaluate(() => window.__folderReads); + await page.evaluate(() => window.__linkedHandlers.windowFocus()); + await expect.poll(() => page.evaluate(() => window.__folderReads)).toBeGreaterThan(reads); + await page.waitForTimeout(500); + await expect(rows).toHaveCount(1); + + const restore = async () => { + if (action === 'rescan') { + await location.dispatchEvent('contextmenu', { clientX: 120, clientY: 120 }); + await page.getByRole('menuitem', { name: 'Rescan folder' }).click(); + } else { + await page.evaluate(() => window.NL_HANDLE_NATIVE_DROP(['C:/Original'])); + } + await expect(rows).toHaveCount(2); + await expect(location).toHaveCount(1); + await expect(page.locator(`#document-tree [data-document-id="${copyId}"]`)).not.toHaveClass(/is-linked-document/); + }; + await restore(); + await restore(); + const linked = page.locator('#document-tree [data-tree-type="document"].is-linked-document').filter({ hasText: 'notes' }); + await expect(linked).toHaveCount(1); + await linked.dispatchEvent('contextmenu', { clientX: 120, clientY: 120 }); + await page.getByRole('menuitem', { name: 'Remove linked file' }).click(); + await expect(rows).toHaveCount(1); + await restore(); + await expect(linked).toHaveCount(1); + await expect.poll(() => page.evaluate(() => { + const index = JSON.parse(window.__desktopFiles.get('C:/Documents/Markdown Viewer Vault/.markdown-viewer/index.json')); + return index.documents.filter(item => item.title === 'notes').length; + })).toBe(2); + const records = await page.evaluate(async id => { + const restarted = new window.MarkdownWorkspaceStorage(); + await restarted.init(); + const metadata = await restarted.listDocumentMetadata(); + const copy = metadata.find(item => item.id === id); + return { copyLinked: Boolean(copy.sourcePath), copyPath: copy._vaultRelativePath, + links: metadata.filter(item => item.sourcePath === 'C:/Original/notes.md').length, + original: window.__desktopFiles.get('C:/Original/notes.md') }; + }, copyId); + expect(records.copyLinked).toBe(false); + expect(records.copyPath).toMatch(/^Workspace\//); + expect(records.links).toBe(1); + expect(records.original).toBe('# original'); + }); +} + +test('linked context menus use the normal danger style and omit Copy source path', async ({ page }) => { + await page.addInitScript('(' + installDesktopFilesystem.toString() + ')(window);'); + await openApp(page); + await page.evaluate(async () => { + await Neutralino.filesystem.createDirectory('C:/Original'); + window.__desktopFiles.set('C:/Original/notes.md', '# original'); + await window.NL_HANDLE_NATIVE_DROP(['C:/Original']); + await window.NL_IMPORT_EXTERNAL_FILE('# loose', 'loose', 'C:/loose.md'); + }); + const menu = page.locator('.document-menu-context.open'); + const tree = page.locator('#document-tree'); + await tree.locator('[data-tree-type="document"]').filter({ hasText: 'Welcome to Markdown' }) + .dispatchEvent('contextmenu', { clientX: 120, clientY: 120 }); + const deleteColor = await menu.getByRole('menuitem', { name: 'Delete', exact: true }).evaluate(item => getComputedStyle(item).color); + await page.keyboard.press('Escape'); + await tree.locator('[data-tree-type="document"]').filter({ hasText: 'loose' }) + .dispatchEvent('contextmenu', { clientX: 120, clientY: 120 }); + await expect(menu.getByRole('menuitem')).toHaveText(['Open', 'Reload from disk', 'Open containing folder', 'Convert to Workspace copy', 'Remove linked file']); + const removeFile = menu.getByRole('menuitem', { name: 'Remove linked file' }); + await expect(removeFile).toHaveClass(/tab-menu-item-danger/); + expect(await removeFile.evaluate(item => getComputedStyle(item).color)).toBe(deleteColor); + await page.keyboard.press('Escape'); + await tree.locator('[data-tree-type="linked-location"]').dispatchEvent('contextmenu', { clientX: 120, clientY: 120 }); + const removeFolder = menu.getByRole('menuitem', { name: 'Remove linked folder' }); + await expect(removeFolder).toHaveClass(/tab-menu-item-danger/); + expect(await removeFolder.evaluate(item => getComputedStyle(item).color)).toBe(deleteColor); +}); + +test('desktop linked autosave uses recovery storage and UI conversion persists a normal Vault copy', async ({ page }) => { + await page.addInitScript('(' + installDesktopFilesystem.toString() + ')(window);'); + await openApp(page); + await page.evaluate(async () => { + window.__desktopFiles.set('C:/Original/notes.md', '# original'); + await window.NL_IMPORT_EXTERNAL_FILE('# original', 'notes', 'C:/Original/notes.md'); + }); + const editor = page.locator('#markdown-editor'); + await editor.fill('# local unsaved draft'); + await editor.dispatchEvent('input'); + const readIndex = () => page.evaluate(() => JSON.parse(window.__desktopFiles.get('C:/Documents/Markdown Viewer Vault/.markdown-viewer/index.json'))); + await expect.poll(async () => (await readIndex()).documents.find(item => item.title === 'notes')?.vaultRelativePath).toMatch(/^Linked Workspace\//); + await expect.poll(() => page.evaluate(() => { + const index = JSON.parse(window.__desktopFiles.get('C:/Documents/Markdown Viewer Vault/.markdown-viewer/index.json')); + return window.__desktopFiles.get('C:/Documents/Markdown Viewer Vault/' + index.documents.find(item => item.title === 'notes').vaultRelativePath); + })).toBe('# local unsaved draft'); + const before = (await readIndex()).documents; + expect(before.find(item => item.title === 'Welcome to Markdown').vaultRelativePath).toMatch(/^Workspace\//); + const oldPath = before.find(item => item.title === 'notes').vaultRelativePath; + const row = page.locator('#document-tree [data-tree-type="document"]').filter({ hasText: 'notes' }); + await row.dispatchEvent('contextmenu', { clientX: 120, clientY: 120 }); + await page.getByRole('menuitem', { name: 'Convert to Workspace copy' }).click(); + const toast = page.locator('.app-toast').filter({ hasText: 'Converted to Workspace copy' }); + await expect(toast.locator('.lucide-check')).toBeVisible(); + expect(await toast.locator('.lucide-check').evaluate(icon => getComputedStyle(icon).maskImage)).not.toBe('none'); + await expect.poll(async () => (await readIndex()).documents.find(item => item.title === 'notes')?.vaultRelativePath).toMatch(/^Workspace\//); + const result = await page.evaluate(async oldRelativePath => { + const restarted = new window.MarkdownWorkspaceStorage(); + await restarted.init(); + const record = (await restarted.listDocumentMetadata()).find(item => item.title === 'notes'); + return { + linked: Boolean(record.sourcePath), + content: await restarted.loadDocumentContent(record.id), + original: window.__desktopFiles.get('C:/Original/notes.md'), + oldRecoveryExists: window.__desktopFiles.has('C:/Documents/Markdown Viewer Vault/' + oldRelativePath) + }; + }, oldPath); + expect(result).toEqual({ linked: false, content: '# local unsaved draft', original: '# original', oldRecoveryExists: false }); +}); + +for (const edited of [false, true]) { + test(`reopening the original keeps an ${edited ? 'edited' : 'identical'} converted Vault copy independent`, async ({ page }) => { + await page.addInitScript('(' + installDesktopFilesystem.toString() + ')(window);'); + await openApp(page); + await page.evaluate(async () => { + window.__desktopFiles.set('C:/Original/notes.md', '# original'); + await window.NL_IMPORT_EXTERNAL_FILE('# original', 'notes', 'C:/Original/notes.md'); + }); + const rows = page.locator('#document-tree [data-tree-type="document"]').filter({ hasText: 'notes' }); + const copyId = await rows.getAttribute('data-document-id'); + await rows.dispatchEvent('contextmenu', { clientX: 120, clientY: 120 }); + await page.getByRole('menuitem', { name: 'Convert to Workspace copy' }).click(); + await expect(rows).not.toHaveClass(/is-linked-document/); + if (edited) { + await page.locator('#markdown-editor').fill('# independent copy edit'); + await page.locator('#markdown-editor').dispatchEvent('input'); + } + await page.evaluate(async () => { + await window.NL_IMPORT_EXTERNAL_FILE('# original', 'notes', 'C:/Original/notes.md'); + await window.NL_IMPORT_EXTERNAL_FILE('# original', 'notes', 'c:\\original\\notes.md'); + }); + await expect(rows).toHaveCount(2); + await expect(rows.filter({ has: page.locator('.lucide-file-symlink') })).toHaveCount(1); + await expect(page.locator(`#document-tree [data-document-id="${copyId}"]`)).not.toHaveClass(/is-linked-document/); + await expect.poll(() => page.evaluate(() => { + const index = JSON.parse(window.__desktopFiles.get('C:/Documents/Markdown Viewer Vault/.markdown-viewer/index.json')); + return index.documents.filter(item => item.title === 'notes').length; + })).toBe(2); + const result = await page.evaluate(async id => { + const restarted = new window.MarkdownWorkspaceStorage(); + await restarted.init(); + const records = (await restarted.listDocumentMetadata()).filter(item => item.title === 'notes'); + const copy = records.find(item => item.id === id); + const linked = records.find(item => item.sourcePath); + return { count: records.length, copyLinked: Boolean(copy.sourcePath), copyPath: copy._vaultRelativePath, + linkedPath: linked._vaultRelativePath, copyContent: await restarted.loadDocumentContent(copy.id), + original: window.__desktopFiles.get('C:/Original/notes.md') }; + }, copyId); + expect(result.count).toBe(2); + expect(result.copyLinked).toBe(false); + expect(result.copyPath).toMatch(/^Workspace\//); + expect(result.linkedPath).toMatch(/^Linked Workspace\//); + expect(result.copyContent).toBe(edited ? '# independent copy edit' : '# original'); + expect(result.original).toBe('# original'); + }); +} diff --git a/tests/e2e/desktop-toast-icons.spec.js b/tests/e2e/desktop-toast-icons.spec.js new file mode 100644 index 00000000..010ec22f --- /dev/null +++ b/tests/e2e/desktop-toast-icons.spec.js @@ -0,0 +1,58 @@ +const { test, expect } = require('@playwright/test'); +const { openApp } = require('../helpers/app'); +const { installDesktopFilesystem } = require('../helpers/desktop-filesystem'); + +for (const theme of ['light', 'dark']) { + test(`linked workflow toast icons are consistent and render in ${theme} theme`, async ({ page }) => { + await page.addInitScript('(' + installDesktopFilesystem.toString() + ')(window); window.NL_TOKEN = "test-token";'); + await openApp(page); + await page.evaluate(theme => { + document.documentElement.setAttribute('data-theme', theme); + window.__desktopFiles.set('C:/Original/notes.md', '# original'); + Neutralino.os.showFolderDialog = async () => 'C:/Original'; + Neutralino.os.showOpenDialog = async () => ['C:/Original/notes.md']; + }, theme); + await page.evaluate(() => Neutralino.filesystem.createDirectory('C:/Original')); + const assertToast = async (text, tone, iconClass) => { + const toast = page.locator('.app-toast').filter({ hasText: text }).last(); + await expect(toast).toBeVisible(); + await expect(toast).toHaveAttribute('data-tone', tone); + const icon = toast.locator('.app-toast-icon-shell > i'); + await expect(icon).toHaveClass('lucide ' + iconClass); + const rendered = await icon.evaluate(element => { + const style = getComputedStyle(element); + return { mask: style.maskImage, width: style.width, height: style.height }; + }); + expect(rendered.mask).not.toBe('none'); + expect(rendered.width).toBe(rendered.height); + expect(parseFloat(rendered.width)).toBeGreaterThan(0); + return toast; + }; + await page.locator('#sidebar-link-folder').click(); + await assertToast('Linked folder scanned.', 'success', 'lucide-check'); + const location = page.locator('#document-tree [data-tree-type="linked-location"]'); + await location.dispatchEvent('contextmenu', { clientX: 120, clientY: 120 }); + await page.getByRole('menuitem', { name: 'Rescan folder' }).click(); + await assertToast('Linked folder scanned.', 'success', 'lucide-check'); + await page.locator('#sidebar-link-files').click(); + await assertToast('Linked files', 'success', 'lucide-check'); + await page.evaluate(async () => { + await window.NL_IMPORT_EXTERNAL_FILE('# loose', 'loose', 'C:/loose.md'); + }); + await assertToast('Linked Markdown file', 'info', 'lucide-info'); + const loose = page.locator('#document-tree [data-tree-type="document"]').filter({ hasText: 'loose' }); + await loose.dispatchEvent('contextmenu', { clientX: 120, clientY: 120 }); + await page.getByRole('menuitem', { name: 'Remove linked file' }).click(); + await assertToast('Linked file closed', 'success', 'lucide-check'); + await location.dispatchEvent('contextmenu', { clientX: 120, clientY: 120 }); + await page.getByRole('menuitem', { name: 'Remove linked folder' }).click(); + const confirmation = await assertToast('Remove linked folder', 'warning', 'lucide-triangle-alert'); + await confirmation.getByRole('button', { name: 'Remove link', exact: true }).click(); + await assertToast('Folder unlinked', 'success', 'lucide-check'); + await page.evaluate(() => { + Neutralino.os.showFolderDialog = async () => { throw new Error('Picker unavailable'); }; + }); + await page.locator('#sidebar-link-folder').click(); + await assertToast('Link failed', 'error', 'lucide-circle-alert'); + }); +} diff --git a/tests/e2e/document-sidebar.spec.js b/tests/e2e/document-sidebar.spec.js index 086ce6d0..93c4c95b 100644 --- a/tests/e2e/document-sidebar.spec.js +++ b/tests/e2e/document-sidebar.spec.js @@ -95,7 +95,8 @@ test('documents open on a single click and collapse all is available', async ({ const closedFolderIcon = workspaceRow.locator('.document-tree-main > i.lucide-folder'); await expect(closedFolderIcon).toHaveCount(1); const closedFolderColor = await closedFolderIcon.evaluate(icon => getComputedStyle(icon).color); - expect(closedFolderColor).not.toBe(openFolderColor); + // The current workspace stays accented; expansion alone does not select a folder. + expect(closedFolderColor).toBe(openFolderColor); await expect(page.locator('#document-sidebar-collapse-all')).toHaveAttribute('aria-label', 'Expand all folders'); await expect(page.locator('#document-sidebar-collapse-all i')).toHaveClass(/lucide-unfold-vertical/); await page.locator('#document-sidebar-collapse-all').click(); @@ -224,7 +225,7 @@ test('Explorer opens and moves multiple selected documents', async ({ page }) => await secondRow.click({ modifiers: ['Control'] }); await secondRow.click({ button: 'right' }); await page.locator('.document-menu-context.open').getByRole('menuitem', { name: 'Move to…' }).click(); - await page.locator('#document-move-destination').selectOption({ label: 'Workspace / Bulk destination' }); + await page.locator('#document-move-destination').selectOption({ label: 'Workspace (Vault) / Bulk destination' }); await page.locator('#document-move-modal-confirm').click(); await expect(firstRow).toHaveAttribute('data-tree-depth', '2'); await expect(secondRow).toHaveAttribute('data-tree-depth', '2'); diff --git a/tests/helpers/desktop-filesystem.js b/tests/helpers/desktop-filesystem.js new file mode 100644 index 00000000..74858abb --- /dev/null +++ b/tests/helpers/desktop-filesystem.js @@ -0,0 +1,70 @@ +// In-memory Neutralino filesystem for testing the real desktop storage backend. +function installDesktopFilesystem(target) { + const files = new Map(); + const directories = new Set(['C:', 'C:/Documents']); + const normalize = value => String(value).replace(/\\/g, '/').replace(/\/+/g, '/').replace(/\/$/, ''); + const join = (...parts) => normalize(parts.filter(Boolean).join('/')); + const storageData = new Map(); + target.__desktopFiles = files; + target.NL_PORT = 1; + target.Neutralino = { + os: { getPath: async () => 'C:/Documents', open: async () => true }, + events: { on: async () => true }, + storage: { + getData: async key => { + if (!storageData.has(key)) throw new Error('missing'); + return storageData.get(key); + }, + setData: async (key, value) => storageData.set(key, value), + removeData: async key => storageData.delete(key) + }, + filesystem: { + getJoinedPath: async (...parts) => join(...parts), + getStats: async path => { + path = normalize(path); + if (directories.has(path)) return { isDirectory: true, size: 0, modifiedAt: Date.now() }; + if (files.has(path)) return { isDirectory: false, size: files.get(path).length, modifiedAt: Date.now() }; + throw new Error('missing'); + }, + createDirectory: async path => directories.add(normalize(path)), + readFile: async path => { + path = normalize(path); + if (!files.has(path)) throw new Error('missing'); + return files.get(path); + }, + writeFile: async (path, value) => files.set(normalize(path), String(value)), + copy: async (source, destination) => files.set(normalize(destination), files.get(normalize(source))), + move: async (source, destination) => { + source = normalize(source); + destination = normalize(destination); + if (!files.has(source)) throw new Error('missing'); + files.set(destination, files.get(source)); + files.delete(source); + }, + remove: async path => { + path = normalize(path); + files.delete(path); + directories.delete(path); + }, + readDirectory: async path => { + const prefix = normalize(path) + '/'; + const entries = new Map(); + directories.forEach(candidate => { + if (!candidate.startsWith(prefix)) return; + const rest = candidate.slice(prefix.length); + if (rest && !rest.includes('/')) entries.set(rest, { entry: rest, type: 'DIRECTORY' }); + }); + files.forEach((_, candidate) => { + if (!candidate.startsWith(prefix)) return; + const rest = candidate.slice(prefix.length); + if (rest && !rest.includes('/')) entries.set(rest, { entry: rest, type: 'FILE' }); + }); + return Array.from(entries.values()); + }, + createWatcher: async () => 1, + removeWatcher: async () => true + } + }; +} + +module.exports = { installDesktopFilesystem }; diff --git a/tests/unit/linked-workspace-storage.test.mjs b/tests/unit/linked-workspace-storage.test.mjs new file mode 100644 index 00000000..10cd8042 --- /dev/null +++ b/tests/unit/linked-workspace-storage.test.mjs @@ -0,0 +1,133 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; +import { webcrypto } from 'node:crypto'; +import mock from '../helpers/desktop-filesystem.js'; + +const source = fs.readFileSync(new URL('../../workspace-storage.js', import.meta.url), 'utf8'); +const vault = 'C:/Documents/Markdown Viewer Vault/'; +const indexPath = vault + '.markdown-viewer/index.json'; + +async function setupLegacyLinkedDraft() { + const target = { crypto: webcrypto, TextEncoder, console, localStorage: { getItem: () => null, removeItem() {} } }; + target.self = target; + target.window = target; + mock.installDesktopFilesystem(target); + vm.runInNewContext(source, target); + const storage = new target.MarkdownWorkspaceStorage(); + await storage.init(); + const draft = { id: 'linked_draft', title: 'Notes', content: '# unsaved draft', contentLoaded: true, workspaceId: 'workspace_default', _storageRevision: 0 }; + await storage.saveDocuments([draft], { folders: [] }, { forceContent: true }); + const metadata = storage.vaultIndex.documents[0]; + metadata.sourcePath = 'C:/Original/notes.md'; + metadata.sourceContentHash = 'original-hash'; + target.__desktopFiles.set(metadata.sourcePath, '# original on disk'); + target.__desktopFiles.set(indexPath, JSON.stringify(storage.vaultIndex)); + return { target, oldPath: vault + metadata.vaultRelativePath }; +} + +test('upgrade moves existing linked recovery drafts without changing originals', async () => { + const { target, oldPath } = await setupLegacyLinkedDraft(); + const upgraded = new target.MarkdownWorkspaceStorage(); + await upgraded.init(); + const record = upgraded.vaultIndex.documents[0]; + assert.match(record.vaultRelativePath, /^Linked Workspace\//); + assert.equal(await upgraded.loadDocumentContent(record.id), '# unsaved draft'); + assert.equal(record.sourcePath, 'C:/Original/notes.md'); + assert.equal(target.__desktopFiles.get(record.sourcePath), '# original on disk'); + assert.equal(target.__desktopFiles.has(oldPath), false); + const restarted = new target.MarkdownWorkspaceStorage(); + await restarted.init(); + assert.equal(await restarted.loadDocumentContent(record.id), '# unsaved draft'); +}); + +test('interrupted linked-draft migration recovers its move and index journals', async () => { + const { target } = await setupLegacyLinkedDraft(); + const write = target.Neutralino.filesystem.writeFile; + let failOnce = true; + target.Neutralino.filesystem.writeFile = async (path, content) => { + if (failOnce && path.includes('/journal/') && path.endsWith('.index.json')) { + failOnce = false; + throw new Error('Simulated interruption before index commit'); + } + return write(path, content); + }; + await assert.rejects(new target.MarkdownWorkspaceStorage().init(), /Simulated interruption/); + const restarted = new target.MarkdownWorkspaceStorage(); + await restarted.init(); + assert.match(restarted.vaultIndex.documents[0].vaultRelativePath, /^Linked Workspace\//); + assert.equal(await restarted.loadDocumentContent('linked_draft'), '# unsaved draft'); + assert.equal(target.__desktopFiles.get('C:/Original/notes.md'), '# original on disk'); +}); + +test('migration stops if an indexed draft body is missing and retains the old index', async () => { + const { target, oldPath } = await setupLegacyLinkedDraft(); + const oldIndex = target.__desktopFiles.get(indexPath); + target.__desktopFiles.delete(oldPath); + await assert.rejects(new target.MarkdownWorkspaceStorage().init(), error => error.name === 'WorkspaceCorruptionError'); + assert.equal(target.__desktopFiles.get(indexPath), oldIndex); +}); + +test('conversion moves the draft to Workspace and backup preserves both storage roots', async () => { + const { target } = await setupLegacyLinkedDraft(); + const storage = new target.MarkdownWorkspaceStorage(); + await storage.init(); + const draft = (await storage.listDocumentMetadata())[0]; + const linkedPath = vault + draft._vaultRelativePath; + assert.match(await storage._backupDocumentRelativePath(draft, {}), /^Linked Workspace\//); + draft.content = await storage.loadDocumentContent(draft.id); + draft.contentLoaded = true; + delete draft.sourcePath; + delete draft.sourceContentHash; + await storage.saveDocuments([draft], { folders: [] }, { changedIds: [draft.id] }); + assert.match(draft._vaultRelativePath, /^Workspace\//); + assert.equal(target.__desktopFiles.has(linkedPath), false); + assert.equal(target.__desktopFiles.get(vault + draft._vaultRelativePath), '# unsaved draft'); + assert.equal(target.__desktopFiles.get('C:/Original/notes.md'), '# original on disk'); + assert.match(await storage._backupDocumentRelativePath(draft, {}), /^Workspace\//); + const restarted = new target.MarkdownWorkspaceStorage(); + await restarted.init(); + assert.equal(restarted.vaultIndex.documents[0].sourcePath, undefined); + assert.match(restarted.vaultIndex.documents[0].vaultRelativePath, /^Workspace\//); +}); + +test('index rebuilding preserves recovery files from Linked Workspace', async () => { + const { target } = await setupLegacyLinkedDraft(); + const storage = new target.MarkdownWorkspaceStorage(); + await storage.init(); + target.__desktopFiles.delete(indexPath); + const rebuilt = new target.MarkdownWorkspaceStorage(); + await rebuilt.init(); + const record = rebuilt.vaultIndex.documents[0]; + assert.equal(record.kind, 'linked-recovery'); + assert.equal(await rebuilt.loadDocumentContent(record.id), '# unsaved draft'); + assert.match(await rebuilt._desktopDocumentRelativePath(record, {}), /^Linked Workspace\//); +}); + +test('new linked drafts and normal documents use separate physical roots', async () => { + const { target } = await setupLegacyLinkedDraft(); + const storage = new target.MarkdownWorkspaceStorage(); + await storage.init(); + const organization = { folders: [{ id: 'folder', name: 'Projects', workspaceId: 'workspace_default', parentFolderId: null }] }; + const linked = { id: 'new_linked', title: 'Same title', content: '# recovery', contentLoaded: true, sourcePath: 'C:/Original/new.md', folderId: 'folder', workspaceId: 'workspace_default' }; + const normal = { id: 'new_normal', title: 'Same title', content: '# normal', contentLoaded: true, folderId: 'folder', workspaceId: 'workspace_default' }; + target.__desktopFiles.set(linked.sourcePath, '# source unchanged'); + await storage.saveDocuments([linked, normal], organization, { forceContent: true }); + assert.match(linked._vaultRelativePath, /^Linked Workspace\//); + assert.match(normal._vaultRelativePath, /^Workspace\/Projects\//); + assert.equal(target.__desktopFiles.get(vault + linked._vaultRelativePath), '# recovery'); + assert.equal(target.__desktopFiles.get(vault + normal._vaultRelativePath), '# normal'); + assert.equal(target.__desktopFiles.get(linked.sourcePath), '# source unchanged'); +}); + +test('migration validates all bodies before moving any legacy draft', async () => { + const { target, oldPath } = await setupLegacyLinkedDraft(); + const index = JSON.parse(target.__desktopFiles.get(indexPath)); + index.documents.push({ ...index.documents[0], id: 'missing_draft', vaultRelativePath: 'Workspace/missing.md' }); + target.__desktopFiles.set(indexPath, JSON.stringify(index)); + const oldIndex = target.__desktopFiles.get(indexPath); + await assert.rejects(new target.MarkdownWorkspaceStorage().init(), error => error.name === 'WorkspaceCorruptionError'); + assert.equal(target.__desktopFiles.get(oldPath), '# unsaved draft'); + assert.equal(target.__desktopFiles.get(indexPath), oldIndex); +}); From 82aeb5fab9bab2fbed10a301230578e0fedb7f0a Mon Sep 17 00:00:00 2001 From: Ioannis Kechagias Date: Sat, 3 Oct 2026 17:18:46 +0300 Subject: [PATCH 13/20] feat(desktop): convert linked sidebar drops into destination Vault copies Uses the existing HTML drop handler; no custom runtime or pointer workaround dependency. Persists conversion before success and restores linked metadata on save failure. --- desktop-app/resources/js/script.js | 32 ++++++++++--- script.js | 32 ++++++++++--- tests/e2e/sidebar-linked-drop.spec.js | 69 +++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 12 deletions(-) create mode 100644 tests/e2e/sidebar-linked-drop.spec.js diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index 60bc53b2..28607626 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -6587,11 +6587,19 @@ document.addEventListener("DOMContentLoaded", async function () { return; } const tabIds = getDraggedSidebarDocumentIds(event.dataTransfer); - if (tabIds.length > 1) moveDocumentsToLocation(tabIds, location.workspaceId, location.folderId || null); - else if (tabIds.length === 1) moveDocumentToLocation(tabIds[0], location.workspaceId, location.folderId || null); + void dropSidebarDocuments(tabIds, location); }); } + async function dropSidebarDocuments(tabIds, location) { + const documents = tabIds.map(function(id) { return tabs.find(function(tab) { return tab.id === id; }); }).filter(Boolean); + if (documents.some(function(tab) { return tab.sourcePath; }) && location.workspaceId !== DEFAULT_WORKSPACE_ID) return; + for (const tab of documents) { + if (tab.sourcePath) await convertLinkedDocumentToVault(tab.id, location); + else await moveDocumentToLocation(tab.id, location.workspaceId, location.folderId || null); + } + } + function createDocumentTreeRow(options) { const row = document.createElement('div'); row.className = 'document-tree-row'; @@ -11667,12 +11675,14 @@ document.addEventListener("DOMContentLoaded", async function () { await Neutralino.os.open(getNativeParentPath(tab.sourcePath)); } - async function convertLinkedDocumentToVault(tabId) { + async function convertLinkedDocumentToVault(tabId, destination) { const tab = tabs.find(function(item) { return item.id === tabId; }); if (!tab || !tab.sourcePath) return; await ensureTabContent(tab); + const previous = Object.assign({}, tab); const sourcePath = tab.sourcePath; const location = tab.linkedRootId ? getLinkedLocation(tab.linkedRootId) : null; + const previousExcluded = location ? (location.excludedPaths || []).slice() : null; if (location && tab.sourceRelativePath) { location.excludedPaths = Array.from(new Set((location.excludedPaths || []).concat(normalizeLinkedRelativePath(tab.sourceRelativePath)))); saveDocumentOrganization(); @@ -11684,10 +11694,19 @@ document.addEventListener("DOMContentLoaded", async function () { delete tab.sourceRelativePath; delete tab.sourceMissing; delete tab.sourceConflict; - tab.workspaceId = DEFAULT_WORKSPACE_ID; - tab.folderId = null; + setDocumentLocation(tab, DEFAULT_WORKSPACE_ID, destination && destination.folderId || null); tab.isOpen = true; - saveTabsToStorage(tabs, [tab.id]); + try { + if (!(await _flushTabsToStorage(tabs, { changedIds: [tab.id] }))) throw new Error('Workspace copy could not be saved.'); + } catch (error) { + Object.keys(tab).forEach(function(key) { delete tab[key]; }); + Object.assign(tab, previous); + if (location) location.excludedPaths = previousExcluded; + saveDocumentOrganization(); + renderDocumentSidebar(); + showAppToast(error.message, { tone: 'error', title: 'Conversion failed' }); + return false; + } renderTabBar(tabs, activeTabId); renderDocumentSidebar(); await refreshLinkedSourceMonitoring(); @@ -11696,6 +11715,7 @@ document.addEventListener("DOMContentLoaded", async function () { icon: 'lucide-check', title: 'Converted to Workspace copy' }); + return true; } async function removeLinkedDocument(tabId) { diff --git a/script.js b/script.js index 60bc53b2..28607626 100644 --- a/script.js +++ b/script.js @@ -6587,11 +6587,19 @@ document.addEventListener("DOMContentLoaded", async function () { return; } const tabIds = getDraggedSidebarDocumentIds(event.dataTransfer); - if (tabIds.length > 1) moveDocumentsToLocation(tabIds, location.workspaceId, location.folderId || null); - else if (tabIds.length === 1) moveDocumentToLocation(tabIds[0], location.workspaceId, location.folderId || null); + void dropSidebarDocuments(tabIds, location); }); } + async function dropSidebarDocuments(tabIds, location) { + const documents = tabIds.map(function(id) { return tabs.find(function(tab) { return tab.id === id; }); }).filter(Boolean); + if (documents.some(function(tab) { return tab.sourcePath; }) && location.workspaceId !== DEFAULT_WORKSPACE_ID) return; + for (const tab of documents) { + if (tab.sourcePath) await convertLinkedDocumentToVault(tab.id, location); + else await moveDocumentToLocation(tab.id, location.workspaceId, location.folderId || null); + } + } + function createDocumentTreeRow(options) { const row = document.createElement('div'); row.className = 'document-tree-row'; @@ -11667,12 +11675,14 @@ document.addEventListener("DOMContentLoaded", async function () { await Neutralino.os.open(getNativeParentPath(tab.sourcePath)); } - async function convertLinkedDocumentToVault(tabId) { + async function convertLinkedDocumentToVault(tabId, destination) { const tab = tabs.find(function(item) { return item.id === tabId; }); if (!tab || !tab.sourcePath) return; await ensureTabContent(tab); + const previous = Object.assign({}, tab); const sourcePath = tab.sourcePath; const location = tab.linkedRootId ? getLinkedLocation(tab.linkedRootId) : null; + const previousExcluded = location ? (location.excludedPaths || []).slice() : null; if (location && tab.sourceRelativePath) { location.excludedPaths = Array.from(new Set((location.excludedPaths || []).concat(normalizeLinkedRelativePath(tab.sourceRelativePath)))); saveDocumentOrganization(); @@ -11684,10 +11694,19 @@ document.addEventListener("DOMContentLoaded", async function () { delete tab.sourceRelativePath; delete tab.sourceMissing; delete tab.sourceConflict; - tab.workspaceId = DEFAULT_WORKSPACE_ID; - tab.folderId = null; + setDocumentLocation(tab, DEFAULT_WORKSPACE_ID, destination && destination.folderId || null); tab.isOpen = true; - saveTabsToStorage(tabs, [tab.id]); + try { + if (!(await _flushTabsToStorage(tabs, { changedIds: [tab.id] }))) throw new Error('Workspace copy could not be saved.'); + } catch (error) { + Object.keys(tab).forEach(function(key) { delete tab[key]; }); + Object.assign(tab, previous); + if (location) location.excludedPaths = previousExcluded; + saveDocumentOrganization(); + renderDocumentSidebar(); + showAppToast(error.message, { tone: 'error', title: 'Conversion failed' }); + return false; + } renderTabBar(tabs, activeTabId); renderDocumentSidebar(); await refreshLinkedSourceMonitoring(); @@ -11696,6 +11715,7 @@ document.addEventListener("DOMContentLoaded", async function () { icon: 'lucide-check', title: 'Converted to Workspace copy' }); + return true; } async function removeLinkedDocument(tabId) { diff --git a/tests/e2e/sidebar-linked-drop.spec.js b/tests/e2e/sidebar-linked-drop.spec.js new file mode 100644 index 00000000..571545aa --- /dev/null +++ b/tests/e2e/sidebar-linked-drop.spec.js @@ -0,0 +1,69 @@ +const { test, expect } = require('@playwright/test'); +const { openApp } = require('../helpers/app'); +const { installDesktopFilesystem } = require('../helpers/desktop-filesystem'); + +// Exercise the existing HTML drop entry point without the custom pointer layer. +async function dropDocument(page, row, target) { + const id = await row.getAttribute('data-document-id'); + const transfer = await page.evaluateHandle(id => { + const data = new DataTransfer(); + data.setData('application/x-markdown-viewer-document', id); + return data; + }, id); + await target.dispatchEvent('drop', { dataTransfer: transfer }); + await transfer.dispose(); +} + +for (const destination of ['root', 'folder']) { + test(`HTML sidebar drop converts linked files into the Vault ${destination}`, async ({ page }) => { + await page.addInitScript('(' + installDesktopFilesystem.toString() + ')(window);'); + await openApp(page); + await page.locator('#sidebar-new-folder').click(); + await page.locator('#document-name-modal-input').fill('Destination'); + await page.locator('#document-name-modal-confirm').click(); + await page.evaluate(async () => { + window.__desktopFiles.set('C:/Original/notes.md', '# original'); + await window.NL_IMPORT_EXTERNAL_FILE('# original', 'notes', 'C:/Original/notes.md'); + }); + const row = page.locator('#document-tree [data-tree-type="document"]').filter({ hasText: 'notes' }); + const secret = page.locator('#document-tree [data-tree-type="workspace"]').filter({ hasText: 'Secret Workspace' }); + await dropDocument(page, row, secret); + await expect(row).toHaveClass(/is-linked-document/); + const target = destination === 'folder' + ? page.locator('#document-tree [data-tree-type="folder"]').filter({ hasText: 'Destination' }) + : page.locator('#document-tree [data-tree-type="workspace"]').filter({ hasText: 'Workspace (Vault)' }); + await dropDocument(page, row, target); + await expect(row).not.toHaveClass(/is-linked-document/); + await expect(row).toHaveAttribute('data-tree-depth', destination === 'folder' ? '2' : '1'); + const result = await page.evaluate(async () => { + const storage = new window.MarkdownWorkspaceStorage(); + await storage.init(); + const record = (await storage.listDocumentMetadata()).find(item => item.title === 'notes'); + return { linked: Boolean(record.sourcePath), path: record.vaultRelativePath, original: window.__desktopFiles.get('C:/Original/notes.md') }; + }); + expect(result.linked).toBe(false); + expect(result.original).toBe('# original'); + expect(result.path).toMatch(destination === 'folder' ? /^Workspace\/Destination\// : /^Workspace\/notes-/); + }); +} + +test('HTML conversion failure preserves the linked document', async ({ page }) => { + await page.addInitScript('(' + installDesktopFilesystem.toString() + ')(window);'); + await openApp(page); + await page.evaluate(async () => { + window.__desktopFiles.set('C:/Original/notes.md', '# original'); + await window.NL_IMPORT_EXTERNAL_FILE('# original', 'notes', 'C:/Original/notes.md'); + await window.MarkdownViewerFlushWorkspace(); + const save = window.MarkdownWorkspaceStorage.prototype.saveDocuments; + window.MarkdownWorkspaceStorage.prototype.saveDocuments = function(documents, ...args) { + if (documents.some(item => item.title === 'notes' && !item.sourcePath)) return Promise.reject(new Error('Simulated disk failure')); + return save.call(this, documents, ...args); + }; + }); + const row = page.locator('#document-tree [data-tree-type="document"]').filter({ hasText: 'notes' }); + const target = page.locator('#document-tree [data-tree-type="workspace"]').filter({ hasText: 'Workspace (Vault)' }); + await dropDocument(page, row, target); + await expect(page.locator('.app-toast').filter({ hasText: 'Conversion failed' })).toBeVisible(); + await expect(row).toHaveClass(/is-linked-document/); + expect(await page.evaluate(() => window.__desktopFiles.get('C:/Original/notes.md'))).toBe('# original'); +}); From 8262567b395d81242be3d81841b0a774b55f90cd Mon Sep 17 00:00:00 2001 From: Ioannis Kechagias Date: Sat, 3 Oct 2026 18:01:20 +0300 Subject: [PATCH 14/20] feat(desktop): preserve source documents when saving new linked copies Make Ctrl+S keyboard-layout independent, propose document filenames with Markdown extensions, protect appended-extension destinations, and add document-specific Save/Save As context actions. Preserve Vault and linked source documents while activating the newly saved linked file. Keep Ctrl+Shift+S unchanged. Include regression coverage for save workflows, overwrite cancellation, and write failures. --- desktop-app/resources/js/script.js | 110 ++++++++----- script.js | 110 ++++++++----- .../desktop-linked-recovery-storage.spec.js | 2 +- tests/e2e/desktop-save.spec.js | 149 ++++++++++++++++++ tests/helpers/desktop-filesystem.js | 2 +- 5 files changed, 291 insertions(+), 82 deletions(-) create mode 100644 tests/e2e/desktop-save.spec.js diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index 28607626..d36a0f4b 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -5898,6 +5898,8 @@ document.addEventListener("DOMContentLoaded", async function () { if (tab && tab.sourcePath && !isTemporaryDocument(tab)) { const linkedActions = [ { id: 'open', icon: 'lucide-file-symlink', label: 'Open', run: function() { openSidebarDocument(tab.id); } }, + { id: 'save', icon: 'lucide-download', label: 'Save', run: function() { void nativeSaveMarkdown({ tabId: tab.id, useLinkedSource: true }); } }, + { id: 'save-as', icon: 'lucide-file-plus-2', label: 'Save As…', run: function() { void nativeSaveMarkdown({ tabId: tab.id, createLinked: true }); } }, { id: 'reload-source', icon: 'lucide-refresh-cw', label: 'Reload from disk', run: function() { void reloadLinkedDocument(tab.id, { force: true }); } }, { id: 'reveal-source', icon: 'lucide-folder-symlink', label: 'Open containing folder', run: function() { void revealLinkedSource(tab); } }, { separator: true }, @@ -5911,6 +5913,9 @@ document.addEventListener("DOMContentLoaded", async function () { id: 'rename', icon: 'lucide-square-pen', label: 'Rename', run: function() { renameTab(tab.id); } }]; if (!isTemporaryDocument(tab)) { + if (typeof Neutralino !== 'undefined' && tab.workspaceId !== SECRET_WORKSPACE_ID) { + actions.push({ id: 'save-as', icon: 'lucide-file-plus-2', label: 'Save As…', run: function() { void nativeSaveMarkdown({ tabId: tab.id, createLinked: true }); } }); + } actions.push({ id: 'duplicate', icon: 'lucide-files', label: 'Duplicate', run: function() { duplicateTab(tab.id); } }); actions.push({ id: 'favorite', @@ -11424,7 +11429,7 @@ document.addEventListener("DOMContentLoaded", async function () { if (settings.open !== false) tab.isOpen = true; tab.lastOpenedAt = Date.now(); } else { - tab = createTab(incomingContent, title || 'Untitled'); + tab = createTab(incomingContent, title || 'Untitled', 'split', { workspaceId: DEFAULT_WORKSPACE_ID, folderId: null }); tab.sourcePath = normalizedPath; tab.sourceContentHash = incomingHash; tab.sourceMissing = false; @@ -11460,7 +11465,7 @@ document.addEventListener("DOMContentLoaded", async function () { } else { await switchTab(tab.id); } - showAppToast('Linked to ' + tab.sourcePath + '. Press Ctrl+S to save changes to the original file.', { + if (settings.notify !== false) showAppToast('Linked to ' + tab.sourcePath + '. Press Ctrl+S to save changes to the original file.', { tone: 'info', title: 'Linked Markdown file' }); @@ -13277,12 +13282,7 @@ document.addEventListener("DOMContentLoaded", async function () { if (typeof Neutralino !== 'undefined') { try { - const result = await Neutralino.os.showSaveDialog("Save Markdown File", { - filters: [ - { name: "Markdown files (*.md)", extensions: ["md", "markdown"] }, - { name: "All files (*.*)", extensions: ["*"] } - ] - }); + const result = await chooseMarkdownSavePath(tab); if (result) { await Neutralino.filesystem.writeFile(result, content); } @@ -24834,51 +24834,81 @@ ${selector} .arrowheadPath { return true; } + async function chooseMarkdownSavePath(tab) { + const chosen = await Neutralino.os.showSaveDialog('Save Markdown File', { + defaultPath: getSafeDocumentFilename(tab && tab.title, 'md', 'document.md'), + filters: [ + { name: 'Markdown files (*.md)', extensions: ['md', 'markdown'] }, + { name: 'All files (*.*)', extensions: ['*'] } + ] + }); + if (!chosen) return null; + const name = getNativePathName(chosen); + const result = name.lastIndexOf('.') > 0 && !name.endsWith('.') ? chosen : chosen.replace(/\.+$/, '') + '.md'; + // The native picker confirmed only the path it returned. If adding .md + // changes that path, explicitly protect the actual destination as well. + if (result !== chosen) { + let exists = false; + try { + await Neutralino.filesystem.getStats(result); + exists = true; + } catch (error) { + if (error.code !== 'NE_FS_NOPATHE') throw error; + } + if (exists && !(await showAppToastConfirmation('Replace the existing file at ' + result + '?', { + title: 'Replace Markdown file?', confirmLabel: 'Replace', dedupeKey: 'save-overwrite:' + result + }))) return null; + } + return result; + } + + let nativeMarkdownSaveInProgress = false; async function nativeSaveMarkdown(options) { + if (nativeMarkdownSaveInProgress) return; + nativeMarkdownSaveInProgress = true; try { const settings = options || {}; - const content = markdownEditor.value; saveCurrentTabState(); - const activeTab = tabs.find(function(t) { return t.id === activeTabId; }); - if (settings.useLinkedSource && activeTab && activeTab.sourcePath) { - await writeLinkedMarkdownSource(activeTab, content); + const tab = tabs.find(function(t) { return t.id === (settings.tabId || activeTabId); }); + if (!tab || isTemporaryDocument(tab)) return; + await ensureTabContent(tab); + const content = typeof tab.content === 'string' ? tab.content : ''; + if (settings.useLinkedSource && tab.sourcePath) { + await writeLinkedMarkdownSource(tab, content); return; } - const result = await Neutralino.os.showSaveDialog("Save Markdown File", { - filters: [ - { name: "Markdown files (*.md)", extensions: ["md", "markdown"] }, - { name: "All files (*.*)", extensions: ["*"] } - ] - }); + const result = await chooseMarkdownSavePath(tab); if (result) { + const createLinked = tab.workspaceId !== SECRET_WORKSPACE_ID && (settings.createLinked || settings.useLinkedSource); + if (createLinked && tab.sourcePath && normalizeLinkedSourcePath(result) === normalizeLinkedSourcePath(tab.sourcePath)) { + showAppToast('Choose a different path to create a new linked file. Use Save to update the current original.', { tone: 'warning', title: 'Choose a new file' }); + return; + } + const existing = createLinked && findTabBySourcePath(result); + if (existing) { + await ensureTabContent(existing); + if (existing.sourceContentHash && await hashLinkedSourceContent(existing.content) !== existing.sourceContentHash) { + showAppToast('This destination already has a linked document with local changes. Choose another file to preserve that draft.', { tone: 'warning', title: 'Unsaved linked draft' }); + return; + } + } await Neutralino.filesystem.writeFile(result, content); + if (await Neutralino.filesystem.readFile(result) !== content) throw new Error('The saved file could not be verified.'); const fileName = result.split(/[/\\]/).pop().replace(/\.(md|markdown)$/i, ""); - if (activeTab) { - activeTab.title = fileName; - activeTab.content = content; - if (settings.useLinkedSource) { - activeTab.sourcePath = result; - activeTab.sourceContentHash = await hashLinkedSourceContent(content); - activeTab.sourceKind = 'linked'; - activeTab.sourceMissing = false; - activeTab.sourceConflict = false; - delete activeTab.linkedRootId; - delete activeTab.sourceRelativePath; - } - saveTabsToStorage(tabs, [activeTab.id]); - renderTabBar(tabs, activeTabId); - renderDocumentSidebar(); - if (settings.useLinkedSource) { - showAppToast('Linked to ' + result + '. Future Ctrl+S saves will update this file.', { - tone: 'success', - title: 'Linked Markdown file' - }); + if (createLinked) { + const linked = await openLinkedMarkdownDocument(content, fileName, result, { notify: false }); + if (!(await _flushTabsToStorage(tabs, { changedIds: [tab.id, linked.id] }))) { + throw new Error('The external file was saved, but its workspace link could not be persisted.'); } + markdownEditor.focus(); } + showAppToast('Saved to ' + result + (createLinked ? '. Future Ctrl+S saves update this linked file.' : ''), { tone: 'success', title: 'Markdown file saved' }); } } catch (e) { console.error("Native save failed:", e); - alert("Native save failed: " + e.message); + showAppToast(e.message, { tone: 'error', title: 'Save failed' }); + } finally { + nativeMarkdownSaveInProgress = false; } } @@ -30008,7 +30038,7 @@ ${selector} .arrowheadPath { } } - if (isCmdOrCtrl && !e.shiftKey && key === 's') { + if (isCmdOrCtrl && !e.shiftKey && !e.altKey && !e.isComposing && (key === 's' || e.code === 'KeyS')) { e.preventDefault(); if (typeof Neutralino !== 'undefined') nativeSaveMarkdown({ useLinkedSource: true }); else exportMd.click(); diff --git a/script.js b/script.js index 28607626..d36a0f4b 100644 --- a/script.js +++ b/script.js @@ -5898,6 +5898,8 @@ document.addEventListener("DOMContentLoaded", async function () { if (tab && tab.sourcePath && !isTemporaryDocument(tab)) { const linkedActions = [ { id: 'open', icon: 'lucide-file-symlink', label: 'Open', run: function() { openSidebarDocument(tab.id); } }, + { id: 'save', icon: 'lucide-download', label: 'Save', run: function() { void nativeSaveMarkdown({ tabId: tab.id, useLinkedSource: true }); } }, + { id: 'save-as', icon: 'lucide-file-plus-2', label: 'Save As…', run: function() { void nativeSaveMarkdown({ tabId: tab.id, createLinked: true }); } }, { id: 'reload-source', icon: 'lucide-refresh-cw', label: 'Reload from disk', run: function() { void reloadLinkedDocument(tab.id, { force: true }); } }, { id: 'reveal-source', icon: 'lucide-folder-symlink', label: 'Open containing folder', run: function() { void revealLinkedSource(tab); } }, { separator: true }, @@ -5911,6 +5913,9 @@ document.addEventListener("DOMContentLoaded", async function () { id: 'rename', icon: 'lucide-square-pen', label: 'Rename', run: function() { renameTab(tab.id); } }]; if (!isTemporaryDocument(tab)) { + if (typeof Neutralino !== 'undefined' && tab.workspaceId !== SECRET_WORKSPACE_ID) { + actions.push({ id: 'save-as', icon: 'lucide-file-plus-2', label: 'Save As…', run: function() { void nativeSaveMarkdown({ tabId: tab.id, createLinked: true }); } }); + } actions.push({ id: 'duplicate', icon: 'lucide-files', label: 'Duplicate', run: function() { duplicateTab(tab.id); } }); actions.push({ id: 'favorite', @@ -11424,7 +11429,7 @@ document.addEventListener("DOMContentLoaded", async function () { if (settings.open !== false) tab.isOpen = true; tab.lastOpenedAt = Date.now(); } else { - tab = createTab(incomingContent, title || 'Untitled'); + tab = createTab(incomingContent, title || 'Untitled', 'split', { workspaceId: DEFAULT_WORKSPACE_ID, folderId: null }); tab.sourcePath = normalizedPath; tab.sourceContentHash = incomingHash; tab.sourceMissing = false; @@ -11460,7 +11465,7 @@ document.addEventListener("DOMContentLoaded", async function () { } else { await switchTab(tab.id); } - showAppToast('Linked to ' + tab.sourcePath + '. Press Ctrl+S to save changes to the original file.', { + if (settings.notify !== false) showAppToast('Linked to ' + tab.sourcePath + '. Press Ctrl+S to save changes to the original file.', { tone: 'info', title: 'Linked Markdown file' }); @@ -13277,12 +13282,7 @@ document.addEventListener("DOMContentLoaded", async function () { if (typeof Neutralino !== 'undefined') { try { - const result = await Neutralino.os.showSaveDialog("Save Markdown File", { - filters: [ - { name: "Markdown files (*.md)", extensions: ["md", "markdown"] }, - { name: "All files (*.*)", extensions: ["*"] } - ] - }); + const result = await chooseMarkdownSavePath(tab); if (result) { await Neutralino.filesystem.writeFile(result, content); } @@ -24834,51 +24834,81 @@ ${selector} .arrowheadPath { return true; } + async function chooseMarkdownSavePath(tab) { + const chosen = await Neutralino.os.showSaveDialog('Save Markdown File', { + defaultPath: getSafeDocumentFilename(tab && tab.title, 'md', 'document.md'), + filters: [ + { name: 'Markdown files (*.md)', extensions: ['md', 'markdown'] }, + { name: 'All files (*.*)', extensions: ['*'] } + ] + }); + if (!chosen) return null; + const name = getNativePathName(chosen); + const result = name.lastIndexOf('.') > 0 && !name.endsWith('.') ? chosen : chosen.replace(/\.+$/, '') + '.md'; + // The native picker confirmed only the path it returned. If adding .md + // changes that path, explicitly protect the actual destination as well. + if (result !== chosen) { + let exists = false; + try { + await Neutralino.filesystem.getStats(result); + exists = true; + } catch (error) { + if (error.code !== 'NE_FS_NOPATHE') throw error; + } + if (exists && !(await showAppToastConfirmation('Replace the existing file at ' + result + '?', { + title: 'Replace Markdown file?', confirmLabel: 'Replace', dedupeKey: 'save-overwrite:' + result + }))) return null; + } + return result; + } + + let nativeMarkdownSaveInProgress = false; async function nativeSaveMarkdown(options) { + if (nativeMarkdownSaveInProgress) return; + nativeMarkdownSaveInProgress = true; try { const settings = options || {}; - const content = markdownEditor.value; saveCurrentTabState(); - const activeTab = tabs.find(function(t) { return t.id === activeTabId; }); - if (settings.useLinkedSource && activeTab && activeTab.sourcePath) { - await writeLinkedMarkdownSource(activeTab, content); + const tab = tabs.find(function(t) { return t.id === (settings.tabId || activeTabId); }); + if (!tab || isTemporaryDocument(tab)) return; + await ensureTabContent(tab); + const content = typeof tab.content === 'string' ? tab.content : ''; + if (settings.useLinkedSource && tab.sourcePath) { + await writeLinkedMarkdownSource(tab, content); return; } - const result = await Neutralino.os.showSaveDialog("Save Markdown File", { - filters: [ - { name: "Markdown files (*.md)", extensions: ["md", "markdown"] }, - { name: "All files (*.*)", extensions: ["*"] } - ] - }); + const result = await chooseMarkdownSavePath(tab); if (result) { + const createLinked = tab.workspaceId !== SECRET_WORKSPACE_ID && (settings.createLinked || settings.useLinkedSource); + if (createLinked && tab.sourcePath && normalizeLinkedSourcePath(result) === normalizeLinkedSourcePath(tab.sourcePath)) { + showAppToast('Choose a different path to create a new linked file. Use Save to update the current original.', { tone: 'warning', title: 'Choose a new file' }); + return; + } + const existing = createLinked && findTabBySourcePath(result); + if (existing) { + await ensureTabContent(existing); + if (existing.sourceContentHash && await hashLinkedSourceContent(existing.content) !== existing.sourceContentHash) { + showAppToast('This destination already has a linked document with local changes. Choose another file to preserve that draft.', { tone: 'warning', title: 'Unsaved linked draft' }); + return; + } + } await Neutralino.filesystem.writeFile(result, content); + if (await Neutralino.filesystem.readFile(result) !== content) throw new Error('The saved file could not be verified.'); const fileName = result.split(/[/\\]/).pop().replace(/\.(md|markdown)$/i, ""); - if (activeTab) { - activeTab.title = fileName; - activeTab.content = content; - if (settings.useLinkedSource) { - activeTab.sourcePath = result; - activeTab.sourceContentHash = await hashLinkedSourceContent(content); - activeTab.sourceKind = 'linked'; - activeTab.sourceMissing = false; - activeTab.sourceConflict = false; - delete activeTab.linkedRootId; - delete activeTab.sourceRelativePath; - } - saveTabsToStorage(tabs, [activeTab.id]); - renderTabBar(tabs, activeTabId); - renderDocumentSidebar(); - if (settings.useLinkedSource) { - showAppToast('Linked to ' + result + '. Future Ctrl+S saves will update this file.', { - tone: 'success', - title: 'Linked Markdown file' - }); + if (createLinked) { + const linked = await openLinkedMarkdownDocument(content, fileName, result, { notify: false }); + if (!(await _flushTabsToStorage(tabs, { changedIds: [tab.id, linked.id] }))) { + throw new Error('The external file was saved, but its workspace link could not be persisted.'); } + markdownEditor.focus(); } + showAppToast('Saved to ' + result + (createLinked ? '. Future Ctrl+S saves update this linked file.' : ''), { tone: 'success', title: 'Markdown file saved' }); } } catch (e) { console.error("Native save failed:", e); - alert("Native save failed: " + e.message); + showAppToast(e.message, { tone: 'error', title: 'Save failed' }); + } finally { + nativeMarkdownSaveInProgress = false; } } @@ -30008,7 +30038,7 @@ ${selector} .arrowheadPath { } } - if (isCmdOrCtrl && !e.shiftKey && key === 's') { + if (isCmdOrCtrl && !e.shiftKey && !e.altKey && !e.isComposing && (key === 's' || e.code === 'KeyS')) { e.preventDefault(); if (typeof Neutralino !== 'undefined') nativeSaveMarkdown({ useLinkedSource: true }); else exportMd.click(); diff --git a/tests/e2e/desktop-linked-recovery-storage.spec.js b/tests/e2e/desktop-linked-recovery-storage.spec.js index 6caab37c..fa12460b 100644 --- a/tests/e2e/desktop-linked-recovery-storage.spec.js +++ b/tests/e2e/desktop-linked-recovery-storage.spec.js @@ -89,7 +89,7 @@ test('linked context menus use the normal danger style and omit Copy source path await page.keyboard.press('Escape'); await tree.locator('[data-tree-type="document"]').filter({ hasText: 'loose' }) .dispatchEvent('contextmenu', { clientX: 120, clientY: 120 }); - await expect(menu.getByRole('menuitem')).toHaveText(['Open', 'Reload from disk', 'Open containing folder', 'Convert to Workspace copy', 'Remove linked file']); + await expect(menu.getByRole('menuitem')).toHaveText(['Open', 'Save', 'Save As…', 'Reload from disk', 'Open containing folder', 'Convert to Workspace copy', 'Remove linked file']); const removeFile = menu.getByRole('menuitem', { name: 'Remove linked file' }); await expect(removeFile).toHaveClass(/tab-menu-item-danger/); expect(await removeFile.evaluate(item => getComputedStyle(item).color)).toBe(deleteColor); diff --git a/tests/e2e/desktop-save.spec.js b/tests/e2e/desktop-save.spec.js new file mode 100644 index 00000000..a4868cae --- /dev/null +++ b/tests/e2e/desktop-save.spec.js @@ -0,0 +1,149 @@ +const { test, expect } = require('@playwright/test'); +const { openApp } = require('../helpers/app'); +const { installDesktopFilesystem } = require('../helpers/desktop-filesystem'); + +async function setup(page) { + await page.addInitScript('(' + installDesktopFilesystem.toString() + ')(window);'); + await openApp(page); + await page.evaluate(() => { + window.__saveChoices = []; + window.__saveDialogs = []; + Neutralino.os.showSaveDialog = async (title, options) => { + window.__saveDialogs.push({ title, options }); + return window.__saveChoices.shift() || ''; + }; + }); +} +async function importNotes(page) { + await page.evaluate(async () => { + window.__desktopFiles.set('C:/Original/notes.md', '# original'); + await window.NL_IMPORT_EXTERNAL_FILE('# original', 'notes', 'C:/Original/notes.md'); + await window.MarkdownViewerFlushWorkspace(); + }); +} +async function menu(page, name, action) { + await page.locator('#document-tree [data-tree-type="document"]').filter({ hasText: name }).dispatchEvent('contextmenu', {clientX:120,clientY:120}); + await page.locator('.document-menu-context.open').getByRole('menuitem', {name:action,exact:true}).click(); +} +async function records(page) { + return page.evaluate(async () => { + const storage = new window.MarkdownWorkspaceStorage(); + await storage.init(); + return storage.listDocumentMetadata(); + }); +} + +test('Greek Ctrl+S creates and focuses a linked copy while preserving the Vault document and proposed filename', async ({ page }) => { + await setup(page); + const originalId = await page.locator('#tab-list .tab-item.active').getAttribute('data-tab-id'); + await page.locator('#markdown-editor').fill('# Vault draft'); + await page.evaluate(() => window.__saveChoices.push('C:/Saved/example')); + await page.locator('#markdown-editor').dispatchEvent('keydown', { key:'σ',code:'KeyS',ctrlKey:true }); + await expect(page.locator('#tab-list .tab-item.active .tab-title')).toHaveText('example'); + await expect(page.locator('#markdown-editor')).toBeFocused(); + expect(await page.evaluate(() => window.__saveDialogs[0].options.defaultPath)).toBe('Welcome to Markdown.md'); + const metadata = await records(page); + expect(metadata.find(item => item.id === originalId).sourcePath).toBeFalsy(); + expect(metadata.find(item => item.sourcePath === 'C:/Saved/example.md')).toBeTruthy(); + expect(await page.evaluate(() => window.__desktopFiles.get('C:/Saved/example.md'))).toBe('# Vault draft'); + await page.locator('#markdown-editor').fill('# updated original'); + await page.locator('#markdown-editor').dispatchEvent('keydown', {key:'σ',code:'KeyS',ctrlKey:true}); + await expect.poll(() => page.evaluate(() => window.__desktopFiles.get('C:/Saved/example.md'))).toBe('# updated original'); + expect(await page.evaluate(() => window.__saveDialogs.length)).toBe(1); +}); + +test('linked Save As creates a second linked document, keeping the old draft and disk original', async ({ page }) => { + await setup(page); + await importNotes(page); + await page.locator('#markdown-editor').fill('# edited draft'); + await page.evaluate(() => window.__saveChoices.push('C:/Saved/copy.MD')); + await menu(page, 'notes', 'Save As…'); + await expect(page.locator('#tab-list .tab-item.active .tab-title')).toHaveText('copy'); + await expect(page.locator('#markdown-editor')).toBeFocused(); + expect(await page.evaluate(() => window.__saveDialogs[0].options.defaultPath)).toBe('notes.md'); + const metadata = await records(page); + expect(metadata.filter(item => item.sourcePath)).toHaveLength(2); + expect(metadata.some(item => item.sourcePath === 'C:/Saved/copy.MD')).toBe(true); + expect(await page.evaluate(() => window.__desktopFiles.get('C:/Original/notes.md'))).toBe('# original'); + await menu(page, 'notes', 'Save'); + await expect.poll(() => page.evaluate(() => window.__desktopFiles.get('C:/Original/notes.md'))).toBe('# edited draft'); + expect(await page.evaluate(() => window.__saveDialogs.length)).toBe(1); + await expect(page.locator('#tab-list .tab-item.active .tab-title')).toHaveText('copy'); +}); + +test('Vault context Save As targets an inactive document and preserves it', async ({ page }) => { + await setup(page); + await page.locator('#markdown-editor').fill('# Vault content'); + await importNotes(page); + await page.evaluate(() => window.__saveChoices.push('C:/Saved/welcome.markdown')); + await menu(page, 'Welcome to Markdown', 'Save As…'); + await expect(page.locator('#tab-list .tab-item.active .tab-title')).toHaveText('welcome'); + expect(await page.evaluate(() => window.__desktopFiles.get('C:/Saved/welcome.markdown'))).toBe('# Vault content'); + expect((await records(page)).some(item => item.title === 'Welcome to Markdown' && !item.sourcePath)).toBe(true); +}); + +test('appended .md overwrite prompts protect the real destination and cancellation changes nothing', async ({ page }) => { + await setup(page); + await page.locator('#markdown-editor').fill('# new'); + await page.evaluate(() => { + window.__desktopFiles.set('C:/Saved/existing.md', '# old'); + window.__saveChoices.push('C:/Saved/existing'); + }); + await menu(page, 'Welcome to Markdown', 'Save As…'); + const toast = page.locator('.app-toast').filter({hasText:'Replace Markdown file?'}); + await expect(toast).toBeVisible(); + await toast.getByRole('button', {name:'Cancel',exact:true}).click(); + expect(await page.evaluate(() => window.__desktopFiles.get('C:/Saved/existing.md'))).toBe('# old'); + expect((await records(page)).filter(item => item.sourcePath)).toHaveLength(0); + await page.evaluate(() => window.__saveChoices.push('C:/Saved/existing')); + await menu(page, 'Welcome to Markdown', 'Save As…'); + await toast.getByRole('button', {name:'Replace',exact:true}).click(); + await expect(page.locator('#tab-list .tab-item.active .tab-title')).toHaveText('existing'); + expect(await page.evaluate(() => window.__desktopFiles.get('C:/Saved/existing.md'))).toBe('# new'); +}); + +test('cancelled or failed Save As does not create a link or change focus', async ({ page }) => { + await setup(page); + await importNotes(page); + const id = await page.locator('#tab-list .tab-item.active').getAttribute('data-tab-id'); + await menu(page, 'notes', 'Save As…'); + await expect.poll(() => page.evaluate(() => window.__saveDialogs.length)).toBe(1); + await page.evaluate(() => { + window.__saveChoices.push('C:/Saved/failure.md'); + const write = Neutralino.filesystem.writeFile; + Neutralino.filesystem.writeFile = async (path, content) => { + if (path === 'C:/Saved/failure.md') throw new Error('Permission denied'); + return write(path, content); + }; + }); + await menu(page, 'notes', 'Save As…'); + await expect(page.locator('.app-toast').filter({hasText:'Save failed'})).toBeVisible(); + await expect(page.locator('#tab-list .tab-item.active')).toHaveAttribute('data-tab-id',id); + expect((await records(page)).filter(item => item.sourcePath)).toHaveLength(1); +}); + +test('Ctrl+Shift+S retains scroll-sync behavior and does not open Save As', async ({ page }) => { + await setup(page); + await page.locator('#markdown-editor').press('Control+Shift+s'); + expect(await page.evaluate(() => window.__saveDialogs.length)).toBe(0); +}); + +test('Save As refuses the current linked source path rather than overwriting it', async ({ page }) => { + await setup(page); + await importNotes(page); + await page.locator('#markdown-editor').fill('# draft'); + await page.evaluate(() => window.__saveChoices.push('C:/Original/notes.md')); + await menu(page, 'notes', 'Save As…'); + await expect(page.locator('.app-toast').filter({hasText:'Choose a new file'})).toBeVisible(); + expect(await page.evaluate(() => window.__desktopFiles.get('C:/Original/notes.md'))).toBe('# original'); + expect((await records(page)).filter(item => item.sourcePath)).toHaveLength(1); +}); + +test('Download Markdown uses the same filename and extension rules without creating a link', async ({ page }) => { + await setup(page); + await page.evaluate(() => window.__saveChoices.push('C:/Saved/download')); + await menu(page, 'Welcome to Markdown', 'Download Markdown'); + await expect.poll(() => page.evaluate(() => window.__desktopFiles.has('C:/Saved/download.md'))).toBe(true); + expect(await page.evaluate(() => window.__saveDialogs[0].options.defaultPath)).toBe('Welcome to Markdown.md'); + expect((await records(page)).filter(item => item.sourcePath)).toHaveLength(0); +}); diff --git a/tests/helpers/desktop-filesystem.js b/tests/helpers/desktop-filesystem.js index 74858abb..80728661 100644 --- a/tests/helpers/desktop-filesystem.js +++ b/tests/helpers/desktop-filesystem.js @@ -24,7 +24,7 @@ function installDesktopFilesystem(target) { path = normalize(path); if (directories.has(path)) return { isDirectory: true, size: 0, modifiedAt: Date.now() }; if (files.has(path)) return { isDirectory: false, size: files.get(path).length, modifiedAt: Date.now() }; - throw new Error('missing'); + throw Object.assign(new Error('missing'), { code: 'NE_FS_NOPATHE' }); }, createDirectory: async path => directories.add(normalize(path)), readFile: async path => { From ee2dab800ceae8d6b7cbeac1318d2367eb2b0cfe Mon Sep 17 00:00:00 2001 From: Ioannis Kechagias Date: Sat, 3 Oct 2026 20:35:47 +0300 Subject: [PATCH 15/20] fix(web): guard linked workspace and native save behind desktop capabilities Keep recovery drafts usable in browsers without exposing native linked controls or writes. Preserve source metadata and use browser downloads for Ctrl+S when desktop APIs are unavailable. --- desktop-app/resources/js/script.js | 77 ++++++++++++++++----------- script.js | 77 ++++++++++++++++----------- tests/e2e/desktop-linked-file.spec.js | 16 ++++-- 3 files changed, 105 insertions(+), 65 deletions(-) diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index d36a0f4b..9fa1fc6f 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -44,6 +44,16 @@ document.addEventListener("DOMContentLoaded", async function () { ); } + function hasDesktopFileAccess() { + return Boolean(typeof Neutralino !== 'undefined' && typeof NL_PORT !== 'undefined' && + Neutralino.filesystem && typeof Neutralino.filesystem.readFile === 'function' && + typeof Neutralino.filesystem.writeFile === 'function' && Neutralino.os); + } + + function isLinkedDocument(tab) { + return Boolean(hasDesktopFileAccess() && tab && tab.sourcePath); + } + const workspaceStorage = typeof window.MarkdownWorkspaceStorage === 'function' ? new window.MarkdownWorkspaceStorage() : null; @@ -5895,7 +5905,7 @@ document.addEventListener("DOMContentLoaded", async function () { } function getDocumentMenuActions(tab) { - if (tab && tab.sourcePath && !isTemporaryDocument(tab)) { + if (isLinkedDocument(tab) && !isTemporaryDocument(tab)) { const linkedActions = [ { id: 'open', icon: 'lucide-file-symlink', label: 'Open', run: function() { openSidebarDocument(tab.id); } }, { id: 'save', icon: 'lucide-download', label: 'Save', run: function() { void nativeSaveMarkdown({ tabId: tab.id, useLinkedSource: true }); } }, @@ -5913,7 +5923,7 @@ document.addEventListener("DOMContentLoaded", async function () { id: 'rename', icon: 'lucide-square-pen', label: 'Rename', run: function() { renameTab(tab.id); } }]; if (!isTemporaryDocument(tab)) { - if (typeof Neutralino !== 'undefined' && tab.workspaceId !== SECRET_WORKSPACE_ID) { + if (hasDesktopFileAccess() && tab.workspaceId !== SECRET_WORKSPACE_ID) { actions.push({ id: 'save-as', icon: 'lucide-file-plus-2', label: 'Save As…', run: function() { void nativeSaveMarkdown({ tabId: tab.id, createLinked: true }); } }); } actions.push({ id: 'duplicate', icon: 'lucide-files', label: 'Duplicate', run: function() { duplicateTab(tab.id); } }); @@ -6598,9 +6608,9 @@ document.addEventListener("DOMContentLoaded", async function () { async function dropSidebarDocuments(tabIds, location) { const documents = tabIds.map(function(id) { return tabs.find(function(tab) { return tab.id === id; }); }).filter(Boolean); - if (documents.some(function(tab) { return tab.sourcePath; }) && location.workspaceId !== DEFAULT_WORKSPACE_ID) return; + if (documents.some(isLinkedDocument) && location.workspaceId !== DEFAULT_WORKSPACE_ID) return; for (const tab of documents) { - if (tab.sourcePath) await convertLinkedDocumentToVault(tab.id, location); + if (isLinkedDocument(tab)) await convertLinkedDocumentToVault(tab.id, location); else await moveDocumentToLocation(tab.id, location.workspaceId, location.folderId || null); } } @@ -6746,9 +6756,9 @@ document.addEventListener("DOMContentLoaded", async function () { id: tab.id, documentId: tab.id, label: tab.title || 'Untitled', - hoverTitle: tab.sourcePath || tab.title || 'Untitled', - ariaLabel: (tab.id === activeTabId ? 'Active document, ' : 'Document, ') + (tab.title || 'Untitled') + (tab.sourcePath ? ', linked file' : '') + (tab.favorite ? ', favorite' : ''), - icon: tab.sourcePath ? 'lucide-file-symlink' : 'lucide-file-text', + hoverTitle: isLinkedDocument(tab) ? tab.sourcePath : tab.title || 'Untitled', + ariaLabel: (tab.id === activeTabId ? 'Active document, ' : 'Document, ') + (tab.title || 'Untitled') + (isLinkedDocument(tab) ? ', linked file' : '') + (tab.favorite ? ', favorite' : ''), + icon: isLinkedDocument(tab) ? 'lucide-file-symlink' : 'lucide-file-text', depth: depth, favorite: tab.favorite === true, temporary: isTemporaryDocument(tab), @@ -6758,12 +6768,12 @@ document.addEventListener("DOMContentLoaded", async function () { openSidebarDocument(tab.id); } }); - if (tab.sourcePath) { + if (isLinkedDocument(tab)) { row.classList.add('is-linked-document'); row.title = tab.sourcePath; } - if (tab.sourceMissing) row.classList.add('is-source-missing'); - if (tab.sourceConflict) row.classList.add('is-source-conflict'); + if (isLinkedDocument(tab) && tab.sourceMissing) row.classList.add('is-source-missing'); + if (isLinkedDocument(tab) && tab.sourceConflict) row.classList.add('is-source-conflict'); container.appendChild(row); if (consumesRenderBudget) { documentSidebarRenderBudget.remaining = Math.max(0, documentSidebarRenderBudget.remaining - 1); @@ -6772,7 +6782,7 @@ document.addEventListener("DOMContentLoaded", async function () { } function getDocumentLocationLabel(tab) { - if (tab && tab.sourcePath) return 'Linked · ' + tab.sourcePath; + if (isLinkedDocument(tab)) return 'Linked · ' + tab.sourcePath; const workspace = getWorkspaceById(tab.workspaceId); const folder = getFolderById(tab.folderId); if (folder && workspace) return workspace.name + ' / ' + getFolderPath(folder.id); @@ -6781,12 +6791,12 @@ document.addEventListener("DOMContentLoaded", async function () { function getDocumentTabHoverTitle(tab) { const title = tab && tab.title ? tab.title : 'Untitled'; - if (tab && tab.sourcePath) return title + ' — Linked file: ' + tab.sourcePath; + if (isLinkedDocument(tab)) return title + ' — Linked file: ' + tab.sourcePath; const folder = tab ? getFolderById(tab.folderId) : null; return folder ? getFolderPath(folder.id) + ' / ' + title : title; } - if (isNeutralinoRuntimeAvailable()) { + if (hasDesktopFileAccess()) { document.querySelectorAll('.desktop-linked-command').forEach(function(element) { element.hidden = false; }); @@ -6815,7 +6825,7 @@ document.addEventListener("DOMContentLoaded", async function () { } let renderedDocuments = 0; documents.forEach(function(tab) { - const meta = tab.sourcePath ? getLinkedDocumentMeta(tab) : getDocumentLocationLabel(tab); + const meta = isLinkedDocument(tab) ? getLinkedDocumentMeta(tab) : getDocumentLocationLabel(tab); if (appendDocumentTreeItem(tree, tab, 0, meta)) renderedDocuments++; }); return renderedDocuments; @@ -6836,7 +6846,7 @@ document.addEventListener("DOMContentLoaded", async function () { } function renderLinkedLocationsTree(tree) { - if (typeof Neutralino === 'undefined') return 0; + if (!hasDesktopFileAccess()) return 0; const linkedTabs = tabs.filter(function(tab) { return !isTemporaryDocument(tab) && Boolean(tab.sourcePath); }); @@ -6985,7 +6995,7 @@ document.addEventListener("DOMContentLoaded", async function () { if (isSecretWorkspace) renderedDocuments += renderLinkedLocationsTree(tree); const secretLocked = isSecretWorkspace && !isSecretWorkspaceUnlocked(); const workspaceDocuments = tabs.filter(function(tab) { - return !isTemporaryDocument(tab) && !tab.sourcePath && tab.workspaceId === workspace.id; + return !isTemporaryDocument(tab) && !isLinkedDocument(tab) && tab.workspaceId === workspace.id; }); const folders = documentOrganization.folders.filter(function(folder) { return folder.workspaceId === workspace.id; }); const matchingDocuments = workspaceDocuments.filter(documentMatchesSidebarSearch); @@ -7016,7 +7026,7 @@ document.addEventListener("DOMContentLoaded", async function () { ? (secretLocked ? 'lucide-shield' : 'lucide-shield-check') : (expanded ? 'lucide-folder-open' : 'lucide-folder'), depth: 0, - currentLocation: tabs.some(function(tab) { return tab.id === activeTabId && !tab.sourcePath && tab.workspaceId === workspace.id; }), + currentLocation: tabs.some(function(tab) { return tab.id === activeTabId && !isLinkedDocument(tab) && tab.workspaceId === workspace.id; }), expanded: expanded, meta: secretLocked ? 'Locked' : String(workspaceDocuments.length), locked: secretLocked, @@ -7301,7 +7311,7 @@ document.addEventListener("DOMContentLoaded", async function () { }).concat(documentOrganization.folders.filter(function(folder) { return unlockedWorkspaceIds.has(folder.workspaceId); })); - if (typeof Neutralino !== 'undefined') { + if (hasDesktopFileAccess()) { items.push({ get expanded() { return documentOrganization.ui.linkedWorkspaceExpanded !== false; }, set expanded(value) { documentOrganization.ui.linkedWorkspaceExpanded = value; } @@ -11139,7 +11149,7 @@ document.addEventListener("DOMContentLoaded", async function () { } catch (_) { // Startup calls this before the workspace tab state is initialized. } - const linkedSourcePath = activeDocument && activeDocument.sourcePath ? activeDocument.sourcePath : ''; + const linkedSourcePath = isLinkedDocument(activeDocument) ? activeDocument.sourcePath : ''; const presentation = { saving: { icon: 'lucide lucide-refresh-cw', text: 'Saving...' }, saved: { icon: 'lucide lucide-check', text: linkedSourcePath ? 'Draft saved · Ctrl+S updates original' : 'All changes saved' }, @@ -11396,6 +11406,7 @@ document.addEventListener("DOMContentLoaded", async function () { } async function openLinkedMarkdownDocument(content, title, sourcePath, options) { + if (!hasDesktopFileAccess()) return null; const settings = options || {}; const normalizedPath = String(sourcePath || '').trim(); const incomingContent = String(content == null ? '' : content); @@ -11514,7 +11525,7 @@ document.addEventListener("DOMContentLoaded", async function () { async function reloadLinkedDocument(tabId, options) { const tab = tabs.find(function(item) { return item.id === tabId; }); - if (!tab || !tab.sourcePath || typeof Neutralino === 'undefined') return false; + if (!isLinkedDocument(tab)) return false; const settings = options || {}; let diskContent; try { @@ -11611,6 +11622,7 @@ document.addEventListener("DOMContentLoaded", async function () { } async function addLinkedFolderPath(folderPath, options) { + if (!hasDesktopFileAccess()) return null; const normalized = normalizeLinkedSourcePath(folderPath); let location = (documentOrganization.linkedLocations || []).find(function(item) { return normalizeLinkedSourcePath(item.path) === normalized; @@ -11646,6 +11658,7 @@ document.addEventListener("DOMContentLoaded", async function () { } async function nativeLinkMarkdownFiles() { + if (!hasDesktopFileAccess()) return 0; try { const paths = await Neutralino.os.showOpenDialog('Link Markdown files', { filters: [{ name: 'Markdown files (*.md, *.markdown)', extensions: ['md', 'markdown'] }], @@ -11666,6 +11679,7 @@ document.addEventListener("DOMContentLoaded", async function () { } async function nativeLinkMarkdownFolder() { + if (!hasDesktopFileAccess()) return; try { const folderPath = await Neutralino.os.showFolderDialog('Link Markdown folder'); if (folderPath) await addLinkedFolderPath(folderPath); @@ -11676,13 +11690,13 @@ document.addEventListener("DOMContentLoaded", async function () { } async function revealLinkedSource(tab) { - if (!tab || !tab.sourcePath) return; + if (!isLinkedDocument(tab)) return; await Neutralino.os.open(getNativeParentPath(tab.sourcePath)); } async function convertLinkedDocumentToVault(tabId, destination) { const tab = tabs.find(function(item) { return item.id === tabId; }); - if (!tab || !tab.sourcePath) return; + if (!isLinkedDocument(tab)) return; await ensureTabContent(tab); const previous = Object.assign({}, tab); const sourcePath = tab.sourcePath; @@ -11762,7 +11776,7 @@ document.addEventListener("DOMContentLoaded", async function () { } async function refreshLinkedSourceMonitoring() { - if (!linkedSourceMonitoringReady || typeof Neutralino === 'undefined' || !Neutralino.filesystem.createWatcher) return; + if (!linkedSourceMonitoringReady || !hasDesktopFileAccess() || !Neutralino.filesystem.createWatcher) return; await stopLinkedSourceMonitoring(); const targets = []; (documentOrganization.linkedLocations || []).forEach(function(location) { @@ -11814,7 +11828,7 @@ document.addEventListener("DOMContentLoaded", async function () { } async function initializeLinkedSourceMonitoring() { - if (linkedSourceMonitoringReady || typeof Neutralino === 'undefined') return; + if (linkedSourceMonitoringReady || !hasDesktopFileAccess()) return; linkedSourceMonitoringReady = true; if (Neutralino.events && typeof Neutralino.events.on === 'function') { await Neutralino.events.on('watchFile', function(event) { @@ -12363,7 +12377,7 @@ document.addEventListener("DOMContentLoaded", async function () { fileIcon.setAttribute('aria-hidden', 'true'); } else { fileIcon = document.createElement('i'); - fileIcon.className = 'lucide ' + (splitPartner ? 'lucide-columns-2' : (tab.sourcePath ? 'lucide-file-symlink' : 'lucide-file-text')) + ' tab-file-icon'; + fileIcon.className = 'lucide ' + (splitPartner ? 'lucide-columns-2' : (isLinkedDocument(tab) ? 'lucide-file-symlink' : 'lucide-file-text')) + ' tab-file-icon'; fileIcon.setAttribute('aria-hidden', 'true'); } @@ -13280,7 +13294,7 @@ document.addEventListener("DOMContentLoaded", async function () { const content = typeof tab.content === 'string' ? tab.content : ''; const filename = getSafeDocumentFilename(tab.title, "md", "document.md"); - if (typeof Neutralino !== 'undefined') { + if (hasDesktopFileAccess()) { try { const result = await chooseMarkdownSavePath(tab); if (result) { @@ -24793,7 +24807,7 @@ ${selector} .arrowheadPath { }); async function writeLinkedMarkdownSource(tab, content) { - if (!tab || !tab.sourcePath) return false; + if (!isLinkedDocument(tab)) return false; let diskContent = null; try { diskContent = await Neutralino.filesystem.readFile(tab.sourcePath); @@ -24835,6 +24849,7 @@ ${selector} .arrowheadPath { } async function chooseMarkdownSavePath(tab) { + if (!hasDesktopFileAccess()) return null; const chosen = await Neutralino.os.showSaveDialog('Save Markdown File', { defaultPath: getSafeDocumentFilename(tab && tab.title, 'md', 'document.md'), filters: [ @@ -24864,7 +24879,7 @@ ${selector} .arrowheadPath { let nativeMarkdownSaveInProgress = false; async function nativeSaveMarkdown(options) { - if (nativeMarkdownSaveInProgress) return; + if (!hasDesktopFileAccess() || nativeMarkdownSaveInProgress) return; nativeMarkdownSaveInProgress = true; try { const settings = options || {}; @@ -25143,7 +25158,7 @@ ${selector} .arrowheadPath { if (!hasActiveOpenDocument()) return; if (isReleaseNotesActive()) return; if (blockShareSnapshotSourceAccess()) return; - if (typeof Neutralino !== 'undefined') { + if (hasDesktopFileAccess()) { nativeSaveMarkdown(); return; } @@ -29932,7 +29947,7 @@ ${selector} .arrowheadPath { document.addEventListener("keydown", function (e) { if (e.defaultPrevented) return; const isCmdOrCtrl = e.ctrlKey || e.metaKey; - const isDesktop = typeof Neutralino !== 'undefined'; + const isDesktop = hasDesktopFileAccess(); const key = e.key.toLowerCase(); if (e.key === 'F3') { @@ -30040,7 +30055,7 @@ ${selector} .arrowheadPath { if (isCmdOrCtrl && !e.shiftKey && !e.altKey && !e.isComposing && (key === 's' || e.code === 'KeyS')) { e.preventDefault(); - if (typeof Neutralino !== 'undefined') nativeSaveMarkdown({ useLinkedSource: true }); + if (hasDesktopFileAccess()) nativeSaveMarkdown({ useLinkedSource: true }); else exportMd.click(); } if (isCmdOrCtrl && key === 'c') { diff --git a/script.js b/script.js index d36a0f4b..9fa1fc6f 100644 --- a/script.js +++ b/script.js @@ -44,6 +44,16 @@ document.addEventListener("DOMContentLoaded", async function () { ); } + function hasDesktopFileAccess() { + return Boolean(typeof Neutralino !== 'undefined' && typeof NL_PORT !== 'undefined' && + Neutralino.filesystem && typeof Neutralino.filesystem.readFile === 'function' && + typeof Neutralino.filesystem.writeFile === 'function' && Neutralino.os); + } + + function isLinkedDocument(tab) { + return Boolean(hasDesktopFileAccess() && tab && tab.sourcePath); + } + const workspaceStorage = typeof window.MarkdownWorkspaceStorage === 'function' ? new window.MarkdownWorkspaceStorage() : null; @@ -5895,7 +5905,7 @@ document.addEventListener("DOMContentLoaded", async function () { } function getDocumentMenuActions(tab) { - if (tab && tab.sourcePath && !isTemporaryDocument(tab)) { + if (isLinkedDocument(tab) && !isTemporaryDocument(tab)) { const linkedActions = [ { id: 'open', icon: 'lucide-file-symlink', label: 'Open', run: function() { openSidebarDocument(tab.id); } }, { id: 'save', icon: 'lucide-download', label: 'Save', run: function() { void nativeSaveMarkdown({ tabId: tab.id, useLinkedSource: true }); } }, @@ -5913,7 +5923,7 @@ document.addEventListener("DOMContentLoaded", async function () { id: 'rename', icon: 'lucide-square-pen', label: 'Rename', run: function() { renameTab(tab.id); } }]; if (!isTemporaryDocument(tab)) { - if (typeof Neutralino !== 'undefined' && tab.workspaceId !== SECRET_WORKSPACE_ID) { + if (hasDesktopFileAccess() && tab.workspaceId !== SECRET_WORKSPACE_ID) { actions.push({ id: 'save-as', icon: 'lucide-file-plus-2', label: 'Save As…', run: function() { void nativeSaveMarkdown({ tabId: tab.id, createLinked: true }); } }); } actions.push({ id: 'duplicate', icon: 'lucide-files', label: 'Duplicate', run: function() { duplicateTab(tab.id); } }); @@ -6598,9 +6608,9 @@ document.addEventListener("DOMContentLoaded", async function () { async function dropSidebarDocuments(tabIds, location) { const documents = tabIds.map(function(id) { return tabs.find(function(tab) { return tab.id === id; }); }).filter(Boolean); - if (documents.some(function(tab) { return tab.sourcePath; }) && location.workspaceId !== DEFAULT_WORKSPACE_ID) return; + if (documents.some(isLinkedDocument) && location.workspaceId !== DEFAULT_WORKSPACE_ID) return; for (const tab of documents) { - if (tab.sourcePath) await convertLinkedDocumentToVault(tab.id, location); + if (isLinkedDocument(tab)) await convertLinkedDocumentToVault(tab.id, location); else await moveDocumentToLocation(tab.id, location.workspaceId, location.folderId || null); } } @@ -6746,9 +6756,9 @@ document.addEventListener("DOMContentLoaded", async function () { id: tab.id, documentId: tab.id, label: tab.title || 'Untitled', - hoverTitle: tab.sourcePath || tab.title || 'Untitled', - ariaLabel: (tab.id === activeTabId ? 'Active document, ' : 'Document, ') + (tab.title || 'Untitled') + (tab.sourcePath ? ', linked file' : '') + (tab.favorite ? ', favorite' : ''), - icon: tab.sourcePath ? 'lucide-file-symlink' : 'lucide-file-text', + hoverTitle: isLinkedDocument(tab) ? tab.sourcePath : tab.title || 'Untitled', + ariaLabel: (tab.id === activeTabId ? 'Active document, ' : 'Document, ') + (tab.title || 'Untitled') + (isLinkedDocument(tab) ? ', linked file' : '') + (tab.favorite ? ', favorite' : ''), + icon: isLinkedDocument(tab) ? 'lucide-file-symlink' : 'lucide-file-text', depth: depth, favorite: tab.favorite === true, temporary: isTemporaryDocument(tab), @@ -6758,12 +6768,12 @@ document.addEventListener("DOMContentLoaded", async function () { openSidebarDocument(tab.id); } }); - if (tab.sourcePath) { + if (isLinkedDocument(tab)) { row.classList.add('is-linked-document'); row.title = tab.sourcePath; } - if (tab.sourceMissing) row.classList.add('is-source-missing'); - if (tab.sourceConflict) row.classList.add('is-source-conflict'); + if (isLinkedDocument(tab) && tab.sourceMissing) row.classList.add('is-source-missing'); + if (isLinkedDocument(tab) && tab.sourceConflict) row.classList.add('is-source-conflict'); container.appendChild(row); if (consumesRenderBudget) { documentSidebarRenderBudget.remaining = Math.max(0, documentSidebarRenderBudget.remaining - 1); @@ -6772,7 +6782,7 @@ document.addEventListener("DOMContentLoaded", async function () { } function getDocumentLocationLabel(tab) { - if (tab && tab.sourcePath) return 'Linked · ' + tab.sourcePath; + if (isLinkedDocument(tab)) return 'Linked · ' + tab.sourcePath; const workspace = getWorkspaceById(tab.workspaceId); const folder = getFolderById(tab.folderId); if (folder && workspace) return workspace.name + ' / ' + getFolderPath(folder.id); @@ -6781,12 +6791,12 @@ document.addEventListener("DOMContentLoaded", async function () { function getDocumentTabHoverTitle(tab) { const title = tab && tab.title ? tab.title : 'Untitled'; - if (tab && tab.sourcePath) return title + ' — Linked file: ' + tab.sourcePath; + if (isLinkedDocument(tab)) return title + ' — Linked file: ' + tab.sourcePath; const folder = tab ? getFolderById(tab.folderId) : null; return folder ? getFolderPath(folder.id) + ' / ' + title : title; } - if (isNeutralinoRuntimeAvailable()) { + if (hasDesktopFileAccess()) { document.querySelectorAll('.desktop-linked-command').forEach(function(element) { element.hidden = false; }); @@ -6815,7 +6825,7 @@ document.addEventListener("DOMContentLoaded", async function () { } let renderedDocuments = 0; documents.forEach(function(tab) { - const meta = tab.sourcePath ? getLinkedDocumentMeta(tab) : getDocumentLocationLabel(tab); + const meta = isLinkedDocument(tab) ? getLinkedDocumentMeta(tab) : getDocumentLocationLabel(tab); if (appendDocumentTreeItem(tree, tab, 0, meta)) renderedDocuments++; }); return renderedDocuments; @@ -6836,7 +6846,7 @@ document.addEventListener("DOMContentLoaded", async function () { } function renderLinkedLocationsTree(tree) { - if (typeof Neutralino === 'undefined') return 0; + if (!hasDesktopFileAccess()) return 0; const linkedTabs = tabs.filter(function(tab) { return !isTemporaryDocument(tab) && Boolean(tab.sourcePath); }); @@ -6985,7 +6995,7 @@ document.addEventListener("DOMContentLoaded", async function () { if (isSecretWorkspace) renderedDocuments += renderLinkedLocationsTree(tree); const secretLocked = isSecretWorkspace && !isSecretWorkspaceUnlocked(); const workspaceDocuments = tabs.filter(function(tab) { - return !isTemporaryDocument(tab) && !tab.sourcePath && tab.workspaceId === workspace.id; + return !isTemporaryDocument(tab) && !isLinkedDocument(tab) && tab.workspaceId === workspace.id; }); const folders = documentOrganization.folders.filter(function(folder) { return folder.workspaceId === workspace.id; }); const matchingDocuments = workspaceDocuments.filter(documentMatchesSidebarSearch); @@ -7016,7 +7026,7 @@ document.addEventListener("DOMContentLoaded", async function () { ? (secretLocked ? 'lucide-shield' : 'lucide-shield-check') : (expanded ? 'lucide-folder-open' : 'lucide-folder'), depth: 0, - currentLocation: tabs.some(function(tab) { return tab.id === activeTabId && !tab.sourcePath && tab.workspaceId === workspace.id; }), + currentLocation: tabs.some(function(tab) { return tab.id === activeTabId && !isLinkedDocument(tab) && tab.workspaceId === workspace.id; }), expanded: expanded, meta: secretLocked ? 'Locked' : String(workspaceDocuments.length), locked: secretLocked, @@ -7301,7 +7311,7 @@ document.addEventListener("DOMContentLoaded", async function () { }).concat(documentOrganization.folders.filter(function(folder) { return unlockedWorkspaceIds.has(folder.workspaceId); })); - if (typeof Neutralino !== 'undefined') { + if (hasDesktopFileAccess()) { items.push({ get expanded() { return documentOrganization.ui.linkedWorkspaceExpanded !== false; }, set expanded(value) { documentOrganization.ui.linkedWorkspaceExpanded = value; } @@ -11139,7 +11149,7 @@ document.addEventListener("DOMContentLoaded", async function () { } catch (_) { // Startup calls this before the workspace tab state is initialized. } - const linkedSourcePath = activeDocument && activeDocument.sourcePath ? activeDocument.sourcePath : ''; + const linkedSourcePath = isLinkedDocument(activeDocument) ? activeDocument.sourcePath : ''; const presentation = { saving: { icon: 'lucide lucide-refresh-cw', text: 'Saving...' }, saved: { icon: 'lucide lucide-check', text: linkedSourcePath ? 'Draft saved · Ctrl+S updates original' : 'All changes saved' }, @@ -11396,6 +11406,7 @@ document.addEventListener("DOMContentLoaded", async function () { } async function openLinkedMarkdownDocument(content, title, sourcePath, options) { + if (!hasDesktopFileAccess()) return null; const settings = options || {}; const normalizedPath = String(sourcePath || '').trim(); const incomingContent = String(content == null ? '' : content); @@ -11514,7 +11525,7 @@ document.addEventListener("DOMContentLoaded", async function () { async function reloadLinkedDocument(tabId, options) { const tab = tabs.find(function(item) { return item.id === tabId; }); - if (!tab || !tab.sourcePath || typeof Neutralino === 'undefined') return false; + if (!isLinkedDocument(tab)) return false; const settings = options || {}; let diskContent; try { @@ -11611,6 +11622,7 @@ document.addEventListener("DOMContentLoaded", async function () { } async function addLinkedFolderPath(folderPath, options) { + if (!hasDesktopFileAccess()) return null; const normalized = normalizeLinkedSourcePath(folderPath); let location = (documentOrganization.linkedLocations || []).find(function(item) { return normalizeLinkedSourcePath(item.path) === normalized; @@ -11646,6 +11658,7 @@ document.addEventListener("DOMContentLoaded", async function () { } async function nativeLinkMarkdownFiles() { + if (!hasDesktopFileAccess()) return 0; try { const paths = await Neutralino.os.showOpenDialog('Link Markdown files', { filters: [{ name: 'Markdown files (*.md, *.markdown)', extensions: ['md', 'markdown'] }], @@ -11666,6 +11679,7 @@ document.addEventListener("DOMContentLoaded", async function () { } async function nativeLinkMarkdownFolder() { + if (!hasDesktopFileAccess()) return; try { const folderPath = await Neutralino.os.showFolderDialog('Link Markdown folder'); if (folderPath) await addLinkedFolderPath(folderPath); @@ -11676,13 +11690,13 @@ document.addEventListener("DOMContentLoaded", async function () { } async function revealLinkedSource(tab) { - if (!tab || !tab.sourcePath) return; + if (!isLinkedDocument(tab)) return; await Neutralino.os.open(getNativeParentPath(tab.sourcePath)); } async function convertLinkedDocumentToVault(tabId, destination) { const tab = tabs.find(function(item) { return item.id === tabId; }); - if (!tab || !tab.sourcePath) return; + if (!isLinkedDocument(tab)) return; await ensureTabContent(tab); const previous = Object.assign({}, tab); const sourcePath = tab.sourcePath; @@ -11762,7 +11776,7 @@ document.addEventListener("DOMContentLoaded", async function () { } async function refreshLinkedSourceMonitoring() { - if (!linkedSourceMonitoringReady || typeof Neutralino === 'undefined' || !Neutralino.filesystem.createWatcher) return; + if (!linkedSourceMonitoringReady || !hasDesktopFileAccess() || !Neutralino.filesystem.createWatcher) return; await stopLinkedSourceMonitoring(); const targets = []; (documentOrganization.linkedLocations || []).forEach(function(location) { @@ -11814,7 +11828,7 @@ document.addEventListener("DOMContentLoaded", async function () { } async function initializeLinkedSourceMonitoring() { - if (linkedSourceMonitoringReady || typeof Neutralino === 'undefined') return; + if (linkedSourceMonitoringReady || !hasDesktopFileAccess()) return; linkedSourceMonitoringReady = true; if (Neutralino.events && typeof Neutralino.events.on === 'function') { await Neutralino.events.on('watchFile', function(event) { @@ -12363,7 +12377,7 @@ document.addEventListener("DOMContentLoaded", async function () { fileIcon.setAttribute('aria-hidden', 'true'); } else { fileIcon = document.createElement('i'); - fileIcon.className = 'lucide ' + (splitPartner ? 'lucide-columns-2' : (tab.sourcePath ? 'lucide-file-symlink' : 'lucide-file-text')) + ' tab-file-icon'; + fileIcon.className = 'lucide ' + (splitPartner ? 'lucide-columns-2' : (isLinkedDocument(tab) ? 'lucide-file-symlink' : 'lucide-file-text')) + ' tab-file-icon'; fileIcon.setAttribute('aria-hidden', 'true'); } @@ -13280,7 +13294,7 @@ document.addEventListener("DOMContentLoaded", async function () { const content = typeof tab.content === 'string' ? tab.content : ''; const filename = getSafeDocumentFilename(tab.title, "md", "document.md"); - if (typeof Neutralino !== 'undefined') { + if (hasDesktopFileAccess()) { try { const result = await chooseMarkdownSavePath(tab); if (result) { @@ -24793,7 +24807,7 @@ ${selector} .arrowheadPath { }); async function writeLinkedMarkdownSource(tab, content) { - if (!tab || !tab.sourcePath) return false; + if (!isLinkedDocument(tab)) return false; let diskContent = null; try { diskContent = await Neutralino.filesystem.readFile(tab.sourcePath); @@ -24835,6 +24849,7 @@ ${selector} .arrowheadPath { } async function chooseMarkdownSavePath(tab) { + if (!hasDesktopFileAccess()) return null; const chosen = await Neutralino.os.showSaveDialog('Save Markdown File', { defaultPath: getSafeDocumentFilename(tab && tab.title, 'md', 'document.md'), filters: [ @@ -24864,7 +24879,7 @@ ${selector} .arrowheadPath { let nativeMarkdownSaveInProgress = false; async function nativeSaveMarkdown(options) { - if (nativeMarkdownSaveInProgress) return; + if (!hasDesktopFileAccess() || nativeMarkdownSaveInProgress) return; nativeMarkdownSaveInProgress = true; try { const settings = options || {}; @@ -25143,7 +25158,7 @@ ${selector} .arrowheadPath { if (!hasActiveOpenDocument()) return; if (isReleaseNotesActive()) return; if (blockShareSnapshotSourceAccess()) return; - if (typeof Neutralino !== 'undefined') { + if (hasDesktopFileAccess()) { nativeSaveMarkdown(); return; } @@ -29932,7 +29947,7 @@ ${selector} .arrowheadPath { document.addEventListener("keydown", function (e) { if (e.defaultPrevented) return; const isCmdOrCtrl = e.ctrlKey || e.metaKey; - const isDesktop = typeof Neutralino !== 'undefined'; + const isDesktop = hasDesktopFileAccess(); const key = e.key.toLowerCase(); if (e.key === 'F3') { @@ -30040,7 +30055,7 @@ ${selector} .arrowheadPath { if (isCmdOrCtrl && !e.shiftKey && !e.altKey && !e.isComposing && (key === 's' || e.code === 'KeyS')) { e.preventDefault(); - if (typeof Neutralino !== 'undefined') nativeSaveMarkdown({ useLinkedSource: true }); + if (hasDesktopFileAccess()) nativeSaveMarkdown({ useLinkedSource: true }); else exportMd.click(); } if (isCmdOrCtrl && key === 'c') { diff --git a/tests/e2e/desktop-linked-file.spec.js b/tests/e2e/desktop-linked-file.spec.js index d005d459..0d64e9da 100644 --- a/tests/e2e/desktop-linked-file.spec.js +++ b/tests/e2e/desktop-linked-file.spec.js @@ -1,6 +1,15 @@ const { test, expect } = require('@playwright/test'); const { openApp, editorValue } = require('../helpers/app'); +// These scenarios simulate desktop linking while retaining browser test storage. +test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + window.NL_PORT = 1; + window.NL_OS = 'Windows'; + window.Neutralino = { filesystem: { readFile: async () => '', writeFile: async () => true }, os: {} }; + }); +}); + test('linked sidebar rows show source paths only on hover across All, Recent and Favs', async ({ page }) => { await page.addInitScript(() => { const records = ['healthy', 'missing', 'conflict'].map((title, index) => ({ @@ -14,7 +23,6 @@ test('linked sidebar rows show source paths only on hover across All, Recent and }); await openApp(page); await page.evaluate(async () => { - window.Neutralino = {}; await window.NL_IMPORT_EXTERNAL_FILE('# healthy', 'healthy', 'C:/Original/healthy.md'); }); const tree = page.locator('#document-tree'); @@ -117,6 +125,7 @@ test('links a folder, discovers external additions, and converts a file to a cop ? window.__folderEntries.map(entry => ({ entry, type: 'FILE' })) : [], getJoinedPath: async (...parts) => parts.join('\\'), readFile: async path => window.__fileContents[path], + writeFile: async () => true, createWatcher: async () => 1, removeWatcher: async () => 1 }, @@ -170,11 +179,13 @@ test('groups linked files under Workspaces and persists independent nested folde readDirectory: async path => directories[path.replace(/\\/g, '/')] || [], getJoinedPath: async (...parts) => parts.join('/'), readFile: async path => '# ' + path, + writeFile: async () => true, createWatcher: async () => 1, removeWatcher: async () => 1 }, events: { on: async () => ({ success: true }) }, - storage: { setData: async () => ({ success: true }) } + storage: { setData: async () => ({ success: true }) }, + os: {} }; Neutralino.os = { showFolderDialog: async () => 'C:/Notes' }; document.getElementById('sidebar-link-folder').click(); @@ -227,7 +238,6 @@ test('groups linked files under Workspaces and persists independent nested folde await page.reload(); await page.waitForFunction(() => document.documentElement.dataset.appReady === 'true'); await page.evaluate(async () => { - window.Neutralino = {}; await window.NL_IMPORT_EXTERNAL_FILE('# loose', 'loose', 'C:/loose.md'); }); await expect(linkedRoot).toHaveAttribute('aria-expanded', 'false'); From c346baa34e32baa076baee772e6a3bdff7eb8c11 Mon Sep 17 00:00:00 2001 From: Ioannis Kechagias Date: Sat, 3 Oct 2026 20:53:39 +0300 Subject: [PATCH 16/20] fix(desktop): preserve context menus during linked background refresh Avoid sidebar redraws for unchanged focus scans and defer background UI changes until the current menu closes. Keep external content synchronization active, batch folder scan rendering, and avoid changing recent-open timestamps during scans. Add regressions for unchanged scans, new files, content updates, and missing sources. --- desktop-app/resources/js/script.js | 67 +++++++++++++++++++++++------ script.js | 67 +++++++++++++++++++++++------ tests/e2e/linked-focus-menu.spec.js | 64 +++++++++++++++++++++++++++ 3 files changed, 174 insertions(+), 24 deletions(-) create mode 100644 tests/e2e/linked-focus-menu.spec.js diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index 9fa1fc6f..d9db929f 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -5771,6 +5771,30 @@ document.addEventListener("DOMContentLoaded", async function () { document.querySelectorAll('.document-menu-dropdown').forEach(function(menu) { menu.remove(); }); } + let linkedBackgroundUIRefreshPending = false; + + function linkedSidebarState() { + return JSON.stringify([ + tabs.filter(function(tab) { return tab.sourcePath; }).map(function(tab) { + return [tab.id, tab.title, tab.sourcePath, Boolean(tab.sourceMissing), Boolean(tab.sourceConflict), + tab.workspaceId, tab.folderId, tab.linkedRootId, tab.sourceRelativePath, tab.isOpen, tab.favorite]; + }), + (documentOrganization.linkedLocations || []).map(function(location) { + return [location.id, location.name, location.path, Boolean(location.unavailable)]; + }) + ]); + } + + function requestLinkedBackgroundUIRefresh() { + if (document.querySelector('.document-menu-dropdown.open, .tab-menu-dropdown.open, [data-tab-context-menu="true"]')) { + linkedBackgroundUIRefreshPending = true; + return; + } + linkedBackgroundUIRefreshPending = false; + renderTabBar(tabs, activeTabId); + renderDocumentSidebar(); + } + function closeDocumentSidebarMenus() { document.querySelectorAll('.document-menu-btn.open').forEach(function(button) { button.classList.remove('open'); @@ -5778,6 +5802,7 @@ document.addEventListener("DOMContentLoaded", async function () { }); document.querySelectorAll('.document-menu-dropdown.open').forEach(function(menu) { menu.classList.remove('open'); }); document.querySelectorAll('.document-menu-context').forEach(function(menu) { menu.remove(); }); + if (linkedBackgroundUIRefreshPending) queueMicrotask(requestLinkedBackgroundUIRefresh); } function openDocumentMenu(button, menu, position, returnFocus, options) { @@ -11421,7 +11446,7 @@ document.addEventListener("DOMContentLoaded", async function () { await ensureTabContent(tab); const draftHash = await hashLinkedSourceContent(tab.content); const hasLocalDraft = Boolean(tab.sourceContentHash && draftHash && draftHash !== tab.sourceContentHash); - if (!hasLocalDraft) { + if (!hasLocalDraft && tab.content !== incomingContent) { tab.content = incomingContent; tab.contentLoaded = true; tab._persistedContent = undefined; @@ -11438,7 +11463,7 @@ document.addEventListener("DOMContentLoaded", async function () { tab.sourceConflict = hasLocalDraft && Boolean(incomingHash && incomingHash !== tab.sourceContentHash); tab.title = title || tab.title; if (settings.open !== false) tab.isOpen = true; - tab.lastOpenedAt = Date.now(); + if (settings.open !== false) tab.lastOpenedAt = Date.now(); } else { tab = createTab(incomingContent, title || 'Untitled', 'split', { workspaceId: DEFAULT_WORKSPACE_ID, folderId: null }); tab.sourcePath = normalizedPath; @@ -11460,8 +11485,10 @@ document.addEventListener("DOMContentLoaded", async function () { markdownEditor.value = tab.content; renderMarkdown(); } - renderTabBar(tabs, activeTabId); - renderDocumentSidebar(); + if (!settings.deferUI) { + renderTabBar(tabs, activeTabId); + renderDocumentSidebar(); + } } else if (settings.activate === false) { activeTabId = tab.id; saveActiveTabId(tab.id); @@ -11527,6 +11554,12 @@ document.addEventListener("DOMContentLoaded", async function () { const tab = tabs.find(function(item) { return item.id === tabId; }); if (!isLinkedDocument(tab)) return false; const settings = options || {}; + const previousSidebarState = linkedSidebarState(); + function refreshUI() { + if (settings.background) { + if (previousSidebarState !== linkedSidebarState()) requestLinkedBackgroundUIRefresh(); + } else renderDocumentSidebar(); + } let diskContent; try { diskContent = await Neutralino.filesystem.readFile(tab.sourcePath); @@ -11534,7 +11567,7 @@ document.addEventListener("DOMContentLoaded", async function () { tab.sourceMissing = true; tab.sourceConflict = false; saveTabsToStorage(tabs, [tab.id]); - renderDocumentSidebar(); + refreshUI(); return false; } await ensureTabContent(tab); @@ -11546,7 +11579,7 @@ document.addEventListener("DOMContentLoaded", async function () { tab.sourceConflict = true; tab.sourceMissing = false; saveTabsToStorage(tabs, [tab.id]); - renderDocumentSidebar(); + refreshUI(); showAppToast('The original changed on disk while a local draft exists. The draft was kept.', { tone: 'info', title: 'Linked file conflict' }); return false; } @@ -11567,8 +11600,8 @@ document.addEventListener("DOMContentLoaded", async function () { markdownEditor.value = tab.content; renderMarkdown(); } - renderTabBar(tabs, activeTabId); - renderDocumentSidebar(); + if (!settings.background) renderTabBar(tabs, activeTabId); + refreshUI(); return true; } @@ -11576,6 +11609,15 @@ document.addEventListener("DOMContentLoaded", async function () { const location = getLinkedLocation(locationId); if (!location) return 0; const settings = options || {}; + const previousSidebarState = linkedSidebarState(); + function refreshUI() { + if (settings.background) { + if (previousSidebarState !== linkedSidebarState()) requestLinkedBackgroundUIRefresh(); + } else { + renderTabBar(tabs, activeTabId); + renderDocumentSidebar(); + } + } let files; try { files = await collectMarkdownFilesFromFolder(location.path); @@ -11583,7 +11625,7 @@ document.addEventListener("DOMContentLoaded", async function () { } catch (error) { location.unavailable = true; saveDocumentOrganization(); - renderDocumentSidebar(); + refreshUI(); if (settings.notify) showAppToast('The linked folder is unavailable: ' + location.path, { tone: 'error', title: 'Folder scan failed' }); return 0; } @@ -11602,6 +11644,7 @@ document.addEventListener("DOMContentLoaded", async function () { await openLinkedMarkdownDocument(content, getNativePathName(file.path).replace(/\.(md|markdown)$/i, ''), file.path, { activate: false, open: false, + deferUI: true, linkedRootId: location.id, sourceRelativePath: file.relativePath }); @@ -11616,7 +11659,7 @@ document.addEventListener("DOMContentLoaded", async function () { location.lastScanAt = Date.now(); saveDocumentOrganization(); saveTabsToStorage(tabs); - renderDocumentSidebar(); + refreshUI(); if (settings.notify) showAppToast('Linked folder scanned. ' + added + ' new Markdown file' + (added === 1 ? '' : 's') + ' found.', { tone: 'success', title: location.name }); return added; } @@ -11816,12 +11859,12 @@ document.addEventListener("DOMContentLoaded", async function () { linkedSourceRefreshTimers.set(key, setTimeout(async function() { linkedSourceRefreshTimers.delete(key); if (watcher.locationId) { - await reconcileLinkedLocation(watcher.locationId); + await reconcileLinkedLocation(watcher.locationId, { background: true }); await refreshLinkedSourceMonitoring(); } else { for (const tab of tabs.filter(function(item) { return item.sourcePath && !item.linkedRootId && normalizeLinkedSourcePath(getNativeParentPath(item.sourcePath)) === normalizeLinkedSourcePath(watcher.path); })) { - await reloadLinkedDocument(tab.id); + await reloadLinkedDocument(tab.id, { background: true }); } } }, 350)); diff --git a/script.js b/script.js index 9fa1fc6f..d9db929f 100644 --- a/script.js +++ b/script.js @@ -5771,6 +5771,30 @@ document.addEventListener("DOMContentLoaded", async function () { document.querySelectorAll('.document-menu-dropdown').forEach(function(menu) { menu.remove(); }); } + let linkedBackgroundUIRefreshPending = false; + + function linkedSidebarState() { + return JSON.stringify([ + tabs.filter(function(tab) { return tab.sourcePath; }).map(function(tab) { + return [tab.id, tab.title, tab.sourcePath, Boolean(tab.sourceMissing), Boolean(tab.sourceConflict), + tab.workspaceId, tab.folderId, tab.linkedRootId, tab.sourceRelativePath, tab.isOpen, tab.favorite]; + }), + (documentOrganization.linkedLocations || []).map(function(location) { + return [location.id, location.name, location.path, Boolean(location.unavailable)]; + }) + ]); + } + + function requestLinkedBackgroundUIRefresh() { + if (document.querySelector('.document-menu-dropdown.open, .tab-menu-dropdown.open, [data-tab-context-menu="true"]')) { + linkedBackgroundUIRefreshPending = true; + return; + } + linkedBackgroundUIRefreshPending = false; + renderTabBar(tabs, activeTabId); + renderDocumentSidebar(); + } + function closeDocumentSidebarMenus() { document.querySelectorAll('.document-menu-btn.open').forEach(function(button) { button.classList.remove('open'); @@ -5778,6 +5802,7 @@ document.addEventListener("DOMContentLoaded", async function () { }); document.querySelectorAll('.document-menu-dropdown.open').forEach(function(menu) { menu.classList.remove('open'); }); document.querySelectorAll('.document-menu-context').forEach(function(menu) { menu.remove(); }); + if (linkedBackgroundUIRefreshPending) queueMicrotask(requestLinkedBackgroundUIRefresh); } function openDocumentMenu(button, menu, position, returnFocus, options) { @@ -11421,7 +11446,7 @@ document.addEventListener("DOMContentLoaded", async function () { await ensureTabContent(tab); const draftHash = await hashLinkedSourceContent(tab.content); const hasLocalDraft = Boolean(tab.sourceContentHash && draftHash && draftHash !== tab.sourceContentHash); - if (!hasLocalDraft) { + if (!hasLocalDraft && tab.content !== incomingContent) { tab.content = incomingContent; tab.contentLoaded = true; tab._persistedContent = undefined; @@ -11438,7 +11463,7 @@ document.addEventListener("DOMContentLoaded", async function () { tab.sourceConflict = hasLocalDraft && Boolean(incomingHash && incomingHash !== tab.sourceContentHash); tab.title = title || tab.title; if (settings.open !== false) tab.isOpen = true; - tab.lastOpenedAt = Date.now(); + if (settings.open !== false) tab.lastOpenedAt = Date.now(); } else { tab = createTab(incomingContent, title || 'Untitled', 'split', { workspaceId: DEFAULT_WORKSPACE_ID, folderId: null }); tab.sourcePath = normalizedPath; @@ -11460,8 +11485,10 @@ document.addEventListener("DOMContentLoaded", async function () { markdownEditor.value = tab.content; renderMarkdown(); } - renderTabBar(tabs, activeTabId); - renderDocumentSidebar(); + if (!settings.deferUI) { + renderTabBar(tabs, activeTabId); + renderDocumentSidebar(); + } } else if (settings.activate === false) { activeTabId = tab.id; saveActiveTabId(tab.id); @@ -11527,6 +11554,12 @@ document.addEventListener("DOMContentLoaded", async function () { const tab = tabs.find(function(item) { return item.id === tabId; }); if (!isLinkedDocument(tab)) return false; const settings = options || {}; + const previousSidebarState = linkedSidebarState(); + function refreshUI() { + if (settings.background) { + if (previousSidebarState !== linkedSidebarState()) requestLinkedBackgroundUIRefresh(); + } else renderDocumentSidebar(); + } let diskContent; try { diskContent = await Neutralino.filesystem.readFile(tab.sourcePath); @@ -11534,7 +11567,7 @@ document.addEventListener("DOMContentLoaded", async function () { tab.sourceMissing = true; tab.sourceConflict = false; saveTabsToStorage(tabs, [tab.id]); - renderDocumentSidebar(); + refreshUI(); return false; } await ensureTabContent(tab); @@ -11546,7 +11579,7 @@ document.addEventListener("DOMContentLoaded", async function () { tab.sourceConflict = true; tab.sourceMissing = false; saveTabsToStorage(tabs, [tab.id]); - renderDocumentSidebar(); + refreshUI(); showAppToast('The original changed on disk while a local draft exists. The draft was kept.', { tone: 'info', title: 'Linked file conflict' }); return false; } @@ -11567,8 +11600,8 @@ document.addEventListener("DOMContentLoaded", async function () { markdownEditor.value = tab.content; renderMarkdown(); } - renderTabBar(tabs, activeTabId); - renderDocumentSidebar(); + if (!settings.background) renderTabBar(tabs, activeTabId); + refreshUI(); return true; } @@ -11576,6 +11609,15 @@ document.addEventListener("DOMContentLoaded", async function () { const location = getLinkedLocation(locationId); if (!location) return 0; const settings = options || {}; + const previousSidebarState = linkedSidebarState(); + function refreshUI() { + if (settings.background) { + if (previousSidebarState !== linkedSidebarState()) requestLinkedBackgroundUIRefresh(); + } else { + renderTabBar(tabs, activeTabId); + renderDocumentSidebar(); + } + } let files; try { files = await collectMarkdownFilesFromFolder(location.path); @@ -11583,7 +11625,7 @@ document.addEventListener("DOMContentLoaded", async function () { } catch (error) { location.unavailable = true; saveDocumentOrganization(); - renderDocumentSidebar(); + refreshUI(); if (settings.notify) showAppToast('The linked folder is unavailable: ' + location.path, { tone: 'error', title: 'Folder scan failed' }); return 0; } @@ -11602,6 +11644,7 @@ document.addEventListener("DOMContentLoaded", async function () { await openLinkedMarkdownDocument(content, getNativePathName(file.path).replace(/\.(md|markdown)$/i, ''), file.path, { activate: false, open: false, + deferUI: true, linkedRootId: location.id, sourceRelativePath: file.relativePath }); @@ -11616,7 +11659,7 @@ document.addEventListener("DOMContentLoaded", async function () { location.lastScanAt = Date.now(); saveDocumentOrganization(); saveTabsToStorage(tabs); - renderDocumentSidebar(); + refreshUI(); if (settings.notify) showAppToast('Linked folder scanned. ' + added + ' new Markdown file' + (added === 1 ? '' : 's') + ' found.', { tone: 'success', title: location.name }); return added; } @@ -11816,12 +11859,12 @@ document.addEventListener("DOMContentLoaded", async function () { linkedSourceRefreshTimers.set(key, setTimeout(async function() { linkedSourceRefreshTimers.delete(key); if (watcher.locationId) { - await reconcileLinkedLocation(watcher.locationId); + await reconcileLinkedLocation(watcher.locationId, { background: true }); await refreshLinkedSourceMonitoring(); } else { for (const tab of tabs.filter(function(item) { return item.sourcePath && !item.linkedRootId && normalizeLinkedSourcePath(getNativeParentPath(item.sourcePath)) === normalizeLinkedSourcePath(watcher.path); })) { - await reloadLinkedDocument(tab.id); + await reloadLinkedDocument(tab.id, { background: true }); } } }, 350)); diff --git a/tests/e2e/linked-focus-menu.spec.js b/tests/e2e/linked-focus-menu.spec.js new file mode 100644 index 00000000..83e5dd8b --- /dev/null +++ b/tests/e2e/linked-focus-menu.spec.js @@ -0,0 +1,64 @@ +const { test, expect } = require('@playwright/test'); +const { openApp } = require('../helpers/app'); +const { installDesktopFilesystem } = require('../helpers/desktop-filesystem'); + +test.beforeEach(async ({ page }) => { + await page.addInitScript('(' + installDesktopFilesystem.toString() + ')(window);'); + await page.addInitScript(() => { + window.__nativeHandlers = {}; + window.Neutralino.events.on = async (name, handler) => { window.__nativeHandlers[name] = handler; }; + }); + await openApp(page); +}); + +for (const change of ['none', 'added', 'content']) { + test(`focus rescan preserves the sidebar menu (${change})`, async ({ page }) => { + await page.evaluate(async () => { + await Neutralino.filesystem.createDirectory('C:/Notes'); + window.__desktopFiles.set('C:/Notes/one.md', '# original'); + await window.NL_HANDLE_NATIVE_DROP(['C:/Notes']); + await window.NL_START_LINKED_MONITORING(); + }); + const row = page.locator('#document-tree [data-tree-type="document"]').filter({hasText:'one'}); + await row.locator('.document-tree-main').click(); + await row.dispatchEvent('contextmenu', {clientX:120,clientY:120}); + const menu = page.locator('.document-menu-context.open'); + await expect(menu).toBeVisible(); + await page.evaluate(change => { + window.__originalMenu = document.querySelector('.document-menu-context.open'); + window.__originalRow = document.querySelector('#document-tree [data-document-id="' + document.querySelector('#tab-list .tab-item.active').dataset.tabId + '"]'); + if (change === 'added') window.__desktopFiles.set('C:/Notes/two.md', '# new'); + if (change === 'content') window.__desktopFiles.set('C:/Notes/one.md', '# external edit'); + window.__nativeHandlers.windowFocus(); + }, change); + // The focus handler debounces its asynchronous scan by 350 ms. + await page.waitForTimeout(800); + await expect(menu).toBeVisible(); + expect(await page.evaluate(() => window.__originalMenu.isConnected)).toBe(true); + expect(await page.evaluate(() => window.__originalRow.isConnected)).toBe(true); + if (change === 'content') await expect(page.locator('#markdown-editor')).toHaveValue('# external edit'); + if (change === 'added') await expect(page.locator('#document-tree [data-tree-type="document"]').filter({hasText:'two'})).toHaveCount(0); + await page.keyboard.press('Escape'); + await expect(menu).toHaveCount(0); + if (change === 'added') await expect(page.locator('#document-tree [data-tree-type="document"]').filter({hasText:'two'})).toBeVisible(); + }); +} + +test('individual missing-source refresh waits until the context menu closes', async ({ page }) => { + await page.evaluate(async () => { + window.__desktopFiles.set('C:/one.md', '# original'); + await window.NL_IMPORT_EXTERNAL_FILE('# original', 'one', 'C:/one.md'); + await window.NL_START_LINKED_MONITORING(); + }); + const row = page.locator('#document-tree [data-tree-type="document"]').filter({hasText:'one'}); + await row.dispatchEvent('contextmenu', {clientX:120,clientY:120}); + await page.evaluate(() => { + window.__desktopFiles.delete('C:/one.md'); + window.__nativeHandlers.windowFocus(); + }); + await page.waitForTimeout(800); + await expect(page.locator('.document-menu-context.open')).toBeVisible(); + await expect(row).not.toHaveClass(/is-source-missing/); + await page.keyboard.press('Escape'); + await expect(row).toHaveClass(/is-source-missing/); +}); From 8c3885a43277acf807526f200969a81184d4fddb Mon Sep 17 00:00:00 2001 From: Ioannis Kechagias Date: Sat, 3 Oct 2026 21:48:59 +0300 Subject: [PATCH 17/20] feat(desktop): add default Linked Workspace guide document Seed an editable internal Markdown guide once for fresh and existing desktop workspaces without taking startup focus. Store it in Linked Workspace without an external source path, respect intentional deletion, and keep native source actions and web seeding disabled. Include guide content, persistence handling, regression tests, and generated desktop scripts. --- assets/linked-workspace-guide.md | 35 +++++++++++ desktop-app/resources/js/script.js | 49 +++++++++++++-- desktop-app/resources/js/workspace-storage.js | 2 +- script.js | 49 +++++++++++++-- tests/e2e/linked-workspace-guide.spec.js | 62 +++++++++++++++++++ workspace-storage.js | 2 +- 6 files changed, 187 insertions(+), 12 deletions(-) create mode 100644 assets/linked-workspace-guide.md create mode 100644 tests/e2e/linked-workspace-guide.spec.js diff --git a/assets/linked-workspace-guide.md b/assets/linked-workspace-guide.md new file mode 100644 index 00000000..2e868c26 --- /dev/null +++ b/assets/linked-workspace-guide.md @@ -0,0 +1,35 @@ +# Welcome to Linked Workspace + +Linked Workspace lets you edit Markdown files where they already live on your computer. + +## Link a file or folder + +Use **Link Markdown files** or **Link a Markdown folder** in the Explorer toolbar or the New menu. A linked folder includes Markdown files in its subfolders. + +Linked documents use a file-with-arrow icon; linked folders use a folder-with-arrow icon. Hover over a linked document to see its original path. + +## Drafts and saving + +Editing automatically stores a recovery draft inside the application's **Linked Workspace** storage folder. It does **not** automatically overwrite your original file. + +- **Ctrl+S** or **Save** updates the original linked file. +- **Save As** creates a new external file and a separate link. The previous document and original remain unchanged. +- Saving a Vault document externally keeps its independent Vault copy and activates the new linked document. + +## Independent Vault copies + +**Convert to Workspace copy** turns a linked draft into an independent Vault document without changing the original. You can also drag a linked document into the Vault or one of its folders. + +Reopening the original creates or activates its link; it never replaces the independent Vault copy. + +## External changes and rescanning + +The application checks linked sources for external changes. If your local draft conflicts with a changed original, it keeps the draft and shows a warning. + +**Rescan folder** discovers new files and restores links previously removed or converted, if the originals still exist. Re-linking the same folder does the same. Automatic background refreshes respect removed links instead of restoring them. + +## Closing and removing + +Closing a tab keeps its document in the workspace. **Remove linked file** or **Remove linked folder** removes links from the application, not the originals from disk. + +This guide is an internal, editable document, not a link to an external file. You can delete it without deleting any source file; it will not reappear on every launch. diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index d9db929f..78161645 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -3,6 +3,7 @@ document.addEventListener("DOMContentLoaded", async function () { const PRIVATE_MODE_KEY = 'markdownViewerPrivateMode'; const APP_VERSION = '3.10.3'; const RELEASE_NOTES_TAB_KIND = 'release-notes'; + const LINKED_WORKSPACE_GUIDE_KIND = 'linked-workspace-guide'; const RELEASE_NOTES_LAST_VERSION_KEY = 'markdownViewerLastVersion'; const RELEASE_NOTES_PENDING_VERSION_KEY = 'markdownViewerPendingReleaseNotesVersion'; const RELEASE_NOTES_PENDING_MODE_KEY = 'markdownViewerPendingReleaseNotesMode'; @@ -54,6 +55,10 @@ document.addEventListener("DOMContentLoaded", async function () { return Boolean(hasDesktopFileAccess() && tab && tab.sourcePath); } + function isLinkedWorkspaceDocument(tab) { + return hasDesktopFileAccess() && Boolean(tab && (tab.sourcePath || tab.kind === LINKED_WORKSPACE_GUIDE_KIND)); + } + const workspaceStorage = typeof window.MarkdownWorkspaceStorage === 'function' ? new window.MarkdownWorkspaceStorage() : null; @@ -4405,6 +4410,7 @@ document.addEventListener("DOMContentLoaded", async function () { ui: { filter: allowedFilters.has(ui.filter) ? ui.filter : 'all', linkedWorkspaceExpanded: ui.linkedWorkspaceExpanded !== false, + linkedGuideInitialized: ui.linkedGuideInitialized === true, collapsed: ui.collapsed === true, width: Number.isFinite(width) ? Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, width)) : 288, lastWorkspaceId: lastWorkspaceId, @@ -5930,6 +5936,14 @@ document.addEventListener("DOMContentLoaded", async function () { } function getDocumentMenuActions(tab) { + if (tab && tab.kind === LINKED_WORKSPACE_GUIDE_KIND) { + return [ + { id: 'open', icon: 'lucide-file-text', label: 'Open', run: function() { openSidebarDocument(tab.id); } }, + { id: 'favorite', icon: tab.favorite ? 'lucide-star-filled' : 'lucide-star', label: tab.favorite ? 'Remove from Favorites' : 'Add to Favorites', run: function() { toggleDocumentFavorite(tab.id); } }, + { id: 'download', icon: 'lucide-download', label: 'Download Markdown', run: function() { downloadTabMarkdown(tab.id); } }, + { id: 'delete', icon: 'lucide-trash-2', label: 'Delete', danger: true, run: function() { deleteTab(tab.id); } } + ]; + } if (isLinkedDocument(tab) && !isTemporaryDocument(tab)) { const linkedActions = [ { id: 'open', icon: 'lucide-file-symlink', label: 'Open', run: function() { openSidebarDocument(tab.id); } }, @@ -6155,6 +6169,10 @@ document.addEventListener("DOMContentLoaded", async function () { }, { separator: true } ); + if (getSelectedDocuments().some(function(tab) { return tab.kind === LINKED_WORKSPACE_GUIDE_KIND; })) { + const moveIndex = actions.findIndex(function(action) { return action.id === 'move-selected'; }); + if (moveIndex >= 0) actions.splice(moveIndex, 1); + } } actions.push({ id: 'delete-selected', @@ -6362,6 +6380,7 @@ document.addEventListener("DOMContentLoaded", async function () { const shouldAnnounce = !options || options.announce !== false; const tab = tabs.find(function(item) { return item.id === tabId; }); if (!tab || isTemporaryDocument(tab)) return false; + if (tab.kind === LINKED_WORKSPACE_GUIDE_KIND) return false; if (workspaceId === SECRET_WORKSPACE_ID && !isSecretWorkspaceUnlocked()) { withUnlockedSecretWorkspace(function() { moveDocumentToLocation(tabId, workspaceId, folderId, options); @@ -6734,7 +6753,7 @@ document.addEventListener("DOMContentLoaded", async function () { if (event.target.closest('input, select, textarea, a')) return; openDocumentTreeContextMenu(row, event); }); - if (options.documentId && !options.temporary) { + if (options.documentId && !options.temporary && !options.disableDrag) { row.draggable = true; row.addEventListener('dragstart', function(event) { const rowSelectionKey = getDocumentTreeSelectionKey('document', options.documentId); @@ -6780,6 +6799,7 @@ document.addEventListener("DOMContentLoaded", async function () { type: 'document', id: tab.id, documentId: tab.id, + disableDrag: tab.kind === LINKED_WORKSPACE_GUIDE_KIND, label: tab.title || 'Untitled', hoverTitle: isLinkedDocument(tab) ? tab.sourcePath : tab.title || 'Untitled', ariaLabel: (tab.id === activeTabId ? 'Active document, ' : 'Document, ') + (tab.title || 'Untitled') + (isLinkedDocument(tab) ? ', linked file' : '') + (tab.favorite ? ', favorite' : ''), @@ -6807,6 +6827,7 @@ document.addEventListener("DOMContentLoaded", async function () { } function getDocumentLocationLabel(tab) { + if (hasDesktopFileAccess() && tab && tab.kind === LINKED_WORKSPACE_GUIDE_KIND) return 'Linked Workspace'; if (isLinkedDocument(tab)) return 'Linked · ' + tab.sourcePath; const workspace = getWorkspaceById(tab.workspaceId); const folder = getFolderById(tab.folderId); @@ -6873,7 +6894,7 @@ document.addEventListener("DOMContentLoaded", async function () { function renderLinkedLocationsTree(tree) { if (!hasDesktopFileAccess()) return 0; const linkedTabs = tabs.filter(function(tab) { - return !isTemporaryDocument(tab) && Boolean(tab.sourcePath); + return !isTemporaryDocument(tab) && isLinkedWorkspaceDocument(tab); }); const locations = Array.isArray(documentOrganization.linkedLocations) ? documentOrganization.linkedLocations : []; let renderedDocuments = 0; @@ -6892,7 +6913,7 @@ document.addEventListener("DOMContentLoaded", async function () { icon: 'lucide-link-2', depth: 0, expanded: workspaceExpanded, - currentLocation: Boolean(activeDocument && activeDocument.sourcePath), + currentLocation: isLinkedWorkspaceDocument(activeDocument), meta: String(linkedTabs.length), onToggle: toggleWorkspace, onActivate: toggleWorkspace @@ -7020,7 +7041,7 @@ document.addEventListener("DOMContentLoaded", async function () { if (isSecretWorkspace) renderedDocuments += renderLinkedLocationsTree(tree); const secretLocked = isSecretWorkspace && !isSecretWorkspaceUnlocked(); const workspaceDocuments = tabs.filter(function(tab) { - return !isTemporaryDocument(tab) && !isLinkedDocument(tab) && tab.workspaceId === workspace.id; + return !isTemporaryDocument(tab) && !isLinkedWorkspaceDocument(tab) && tab.workspaceId === workspace.id; }); const folders = documentOrganization.folders.filter(function(folder) { return folder.workspaceId === workspace.id; }); const matchingDocuments = workspaceDocuments.filter(documentMatchesSidebarSearch); @@ -7051,7 +7072,7 @@ document.addEventListener("DOMContentLoaded", async function () { ? (secretLocked ? 'lucide-shield' : 'lucide-shield-check') : (expanded ? 'lucide-folder-open' : 'lucide-folder'), depth: 0, - currentLocation: tabs.some(function(tab) { return tab.id === activeTabId && !isLinkedDocument(tab) && tab.workspaceId === workspace.id; }), + currentLocation: tabs.some(function(tab) { return tab.id === activeTabId && !isLinkedWorkspaceDocument(tab) && tab.workspaceId === workspace.id; }), expanded: expanded, meta: secretLocked ? 'Locked' : String(workspaceDocuments.length), locked: secretLocked, @@ -13535,6 +13556,24 @@ document.addEventListener("DOMContentLoaded", async function () { saveActiveTabId(activeTabId); } + // Seed a real, editable internal guide once, including existing installations. + // It has no external source path and must never be watched or written back. + if (hasDesktopFileAccess() && !documentOrganization.ui.linkedGuideInitialized) { + if (!tabs.some(function(tab) { return tab.kind === LINKED_WORKSPACE_GUIDE_KIND; })) { + const response = await fetch('assets/linked-workspace-guide.md'); + if (!response.ok) throw new Error('Unable to load the Linked Workspace guide.'); + const guide = createTab(await response.text(), 'Welcome to Linked Workspace', 'preview', { + workspaceId: DEFAULT_WORKSPACE_ID, folderId: null + }); + guide.kind = LINKED_WORKSPACE_GUIDE_KIND; + guide.isOpen = false; + tabs.push(guide); + if (!(await _flushTabsToStorage(tabs, { changedIds: [guide.id] }))) throw new Error('Unable to save the Linked Workspace guide.'); + } + documentOrganization.ui.linkedGuideInitialized = true; + saveDocumentOrganization(); + } + // Check if Neutralino passed an initial file via command line (early load) if (window.NL_INITIAL_FILE_CONTENT) { const initialFile = window.NL_INITIAL_FILE_CONTENT; diff --git a/desktop-app/resources/js/workspace-storage.js b/desktop-app/resources/js/workspace-storage.js index 566b5b38..85671e9b 100644 --- a/desktop-app/resources/js/workspace-storage.js +++ b/desktop-app/resources/js/workspace-storage.js @@ -1121,7 +1121,7 @@ } async _desktopDocumentRelativePath(tab, organization) { - const linked = Boolean(tab.sourcePath) || tab.kind === 'linked-recovery'; + const linked = Boolean(tab.sourcePath) || tab.kind === 'linked-recovery' || tab.kind === 'linked-workspace-guide'; const segments = linked ? ['Linked Workspace'] : ['Workspace'].concat(await this._desktopFolderSegments(tab, organization)); requireDocumentId(tab && tab.id); const suffix = await stableDocumentIdSuffix(tab.id); diff --git a/script.js b/script.js index d9db929f..78161645 100644 --- a/script.js +++ b/script.js @@ -3,6 +3,7 @@ document.addEventListener("DOMContentLoaded", async function () { const PRIVATE_MODE_KEY = 'markdownViewerPrivateMode'; const APP_VERSION = '3.10.3'; const RELEASE_NOTES_TAB_KIND = 'release-notes'; + const LINKED_WORKSPACE_GUIDE_KIND = 'linked-workspace-guide'; const RELEASE_NOTES_LAST_VERSION_KEY = 'markdownViewerLastVersion'; const RELEASE_NOTES_PENDING_VERSION_KEY = 'markdownViewerPendingReleaseNotesVersion'; const RELEASE_NOTES_PENDING_MODE_KEY = 'markdownViewerPendingReleaseNotesMode'; @@ -54,6 +55,10 @@ document.addEventListener("DOMContentLoaded", async function () { return Boolean(hasDesktopFileAccess() && tab && tab.sourcePath); } + function isLinkedWorkspaceDocument(tab) { + return hasDesktopFileAccess() && Boolean(tab && (tab.sourcePath || tab.kind === LINKED_WORKSPACE_GUIDE_KIND)); + } + const workspaceStorage = typeof window.MarkdownWorkspaceStorage === 'function' ? new window.MarkdownWorkspaceStorage() : null; @@ -4405,6 +4410,7 @@ document.addEventListener("DOMContentLoaded", async function () { ui: { filter: allowedFilters.has(ui.filter) ? ui.filter : 'all', linkedWorkspaceExpanded: ui.linkedWorkspaceExpanded !== false, + linkedGuideInitialized: ui.linkedGuideInitialized === true, collapsed: ui.collapsed === true, width: Number.isFinite(width) ? Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, width)) : 288, lastWorkspaceId: lastWorkspaceId, @@ -5930,6 +5936,14 @@ document.addEventListener("DOMContentLoaded", async function () { } function getDocumentMenuActions(tab) { + if (tab && tab.kind === LINKED_WORKSPACE_GUIDE_KIND) { + return [ + { id: 'open', icon: 'lucide-file-text', label: 'Open', run: function() { openSidebarDocument(tab.id); } }, + { id: 'favorite', icon: tab.favorite ? 'lucide-star-filled' : 'lucide-star', label: tab.favorite ? 'Remove from Favorites' : 'Add to Favorites', run: function() { toggleDocumentFavorite(tab.id); } }, + { id: 'download', icon: 'lucide-download', label: 'Download Markdown', run: function() { downloadTabMarkdown(tab.id); } }, + { id: 'delete', icon: 'lucide-trash-2', label: 'Delete', danger: true, run: function() { deleteTab(tab.id); } } + ]; + } if (isLinkedDocument(tab) && !isTemporaryDocument(tab)) { const linkedActions = [ { id: 'open', icon: 'lucide-file-symlink', label: 'Open', run: function() { openSidebarDocument(tab.id); } }, @@ -6155,6 +6169,10 @@ document.addEventListener("DOMContentLoaded", async function () { }, { separator: true } ); + if (getSelectedDocuments().some(function(tab) { return tab.kind === LINKED_WORKSPACE_GUIDE_KIND; })) { + const moveIndex = actions.findIndex(function(action) { return action.id === 'move-selected'; }); + if (moveIndex >= 0) actions.splice(moveIndex, 1); + } } actions.push({ id: 'delete-selected', @@ -6362,6 +6380,7 @@ document.addEventListener("DOMContentLoaded", async function () { const shouldAnnounce = !options || options.announce !== false; const tab = tabs.find(function(item) { return item.id === tabId; }); if (!tab || isTemporaryDocument(tab)) return false; + if (tab.kind === LINKED_WORKSPACE_GUIDE_KIND) return false; if (workspaceId === SECRET_WORKSPACE_ID && !isSecretWorkspaceUnlocked()) { withUnlockedSecretWorkspace(function() { moveDocumentToLocation(tabId, workspaceId, folderId, options); @@ -6734,7 +6753,7 @@ document.addEventListener("DOMContentLoaded", async function () { if (event.target.closest('input, select, textarea, a')) return; openDocumentTreeContextMenu(row, event); }); - if (options.documentId && !options.temporary) { + if (options.documentId && !options.temporary && !options.disableDrag) { row.draggable = true; row.addEventListener('dragstart', function(event) { const rowSelectionKey = getDocumentTreeSelectionKey('document', options.documentId); @@ -6780,6 +6799,7 @@ document.addEventListener("DOMContentLoaded", async function () { type: 'document', id: tab.id, documentId: tab.id, + disableDrag: tab.kind === LINKED_WORKSPACE_GUIDE_KIND, label: tab.title || 'Untitled', hoverTitle: isLinkedDocument(tab) ? tab.sourcePath : tab.title || 'Untitled', ariaLabel: (tab.id === activeTabId ? 'Active document, ' : 'Document, ') + (tab.title || 'Untitled') + (isLinkedDocument(tab) ? ', linked file' : '') + (tab.favorite ? ', favorite' : ''), @@ -6807,6 +6827,7 @@ document.addEventListener("DOMContentLoaded", async function () { } function getDocumentLocationLabel(tab) { + if (hasDesktopFileAccess() && tab && tab.kind === LINKED_WORKSPACE_GUIDE_KIND) return 'Linked Workspace'; if (isLinkedDocument(tab)) return 'Linked · ' + tab.sourcePath; const workspace = getWorkspaceById(tab.workspaceId); const folder = getFolderById(tab.folderId); @@ -6873,7 +6894,7 @@ document.addEventListener("DOMContentLoaded", async function () { function renderLinkedLocationsTree(tree) { if (!hasDesktopFileAccess()) return 0; const linkedTabs = tabs.filter(function(tab) { - return !isTemporaryDocument(tab) && Boolean(tab.sourcePath); + return !isTemporaryDocument(tab) && isLinkedWorkspaceDocument(tab); }); const locations = Array.isArray(documentOrganization.linkedLocations) ? documentOrganization.linkedLocations : []; let renderedDocuments = 0; @@ -6892,7 +6913,7 @@ document.addEventListener("DOMContentLoaded", async function () { icon: 'lucide-link-2', depth: 0, expanded: workspaceExpanded, - currentLocation: Boolean(activeDocument && activeDocument.sourcePath), + currentLocation: isLinkedWorkspaceDocument(activeDocument), meta: String(linkedTabs.length), onToggle: toggleWorkspace, onActivate: toggleWorkspace @@ -7020,7 +7041,7 @@ document.addEventListener("DOMContentLoaded", async function () { if (isSecretWorkspace) renderedDocuments += renderLinkedLocationsTree(tree); const secretLocked = isSecretWorkspace && !isSecretWorkspaceUnlocked(); const workspaceDocuments = tabs.filter(function(tab) { - return !isTemporaryDocument(tab) && !isLinkedDocument(tab) && tab.workspaceId === workspace.id; + return !isTemporaryDocument(tab) && !isLinkedWorkspaceDocument(tab) && tab.workspaceId === workspace.id; }); const folders = documentOrganization.folders.filter(function(folder) { return folder.workspaceId === workspace.id; }); const matchingDocuments = workspaceDocuments.filter(documentMatchesSidebarSearch); @@ -7051,7 +7072,7 @@ document.addEventListener("DOMContentLoaded", async function () { ? (secretLocked ? 'lucide-shield' : 'lucide-shield-check') : (expanded ? 'lucide-folder-open' : 'lucide-folder'), depth: 0, - currentLocation: tabs.some(function(tab) { return tab.id === activeTabId && !isLinkedDocument(tab) && tab.workspaceId === workspace.id; }), + currentLocation: tabs.some(function(tab) { return tab.id === activeTabId && !isLinkedWorkspaceDocument(tab) && tab.workspaceId === workspace.id; }), expanded: expanded, meta: secretLocked ? 'Locked' : String(workspaceDocuments.length), locked: secretLocked, @@ -13535,6 +13556,24 @@ document.addEventListener("DOMContentLoaded", async function () { saveActiveTabId(activeTabId); } + // Seed a real, editable internal guide once, including existing installations. + // It has no external source path and must never be watched or written back. + if (hasDesktopFileAccess() && !documentOrganization.ui.linkedGuideInitialized) { + if (!tabs.some(function(tab) { return tab.kind === LINKED_WORKSPACE_GUIDE_KIND; })) { + const response = await fetch('assets/linked-workspace-guide.md'); + if (!response.ok) throw new Error('Unable to load the Linked Workspace guide.'); + const guide = createTab(await response.text(), 'Welcome to Linked Workspace', 'preview', { + workspaceId: DEFAULT_WORKSPACE_ID, folderId: null + }); + guide.kind = LINKED_WORKSPACE_GUIDE_KIND; + guide.isOpen = false; + tabs.push(guide); + if (!(await _flushTabsToStorage(tabs, { changedIds: [guide.id] }))) throw new Error('Unable to save the Linked Workspace guide.'); + } + documentOrganization.ui.linkedGuideInitialized = true; + saveDocumentOrganization(); + } + // Check if Neutralino passed an initial file via command line (early load) if (window.NL_INITIAL_FILE_CONTENT) { const initialFile = window.NL_INITIAL_FILE_CONTENT; diff --git a/tests/e2e/linked-workspace-guide.spec.js b/tests/e2e/linked-workspace-guide.spec.js new file mode 100644 index 00000000..aafb1d9d --- /dev/null +++ b/tests/e2e/linked-workspace-guide.spec.js @@ -0,0 +1,62 @@ +const { test, expect } = require('@playwright/test'); +const { openApp } = require('../helpers/app'); +const { installDesktopFilesystem } = require('../helpers/desktop-filesystem'); + +test('desktop guide is stored separately, editable, and does not take startup focus', async ({ page }) => { + await page.addInitScript('(' + installDesktopFilesystem.toString() + ')(window);'); + await openApp(page); + const guide = page.locator('#document-tree [data-tree-type="document"]').filter({hasText:'Welcome to Linked Workspace'}); + await expect(guide).toHaveCount(1); + await expect(guide).not.toHaveClass(/is-linked-document/); + await expect(page.locator('#tab-list .tab-item.active .tab-title')).toHaveText('Welcome to Markdown'); + expect(await page.evaluate(() => Array.from(window.__desktopFiles.keys()).some(path => path.includes('/Linked Workspace/Welcome to Linked Workspace--')))).toBe(true); + await guide.locator('.document-tree-main').click(); + await expect(page.locator('#markdown-editor')).toHaveValue(/# Welcome to Linked Workspace/); + await expect(page.locator('#markdown-preview')).toContainText('Welcome to Linked Workspace'); + await guide.dispatchEvent('contextmenu', {clientX:120,clientY:120}); + const menu = page.locator('.document-menu-context.open'); + for (const name of ['Save', 'Reload from disk', 'Open containing folder', 'Remove linked file']) { + await expect(menu.getByRole('menuitem',{name,exact:true})).toHaveCount(0); + } + await expect(menu.getByRole('menuitem',{name:'Delete',exact:true})).toBeVisible(); + await page.keyboard.press('Escape'); + await page.locator('.header-view-toolbar [data-view-mode="editor"]').click(); + await page.locator('#markdown-editor').fill('# Edited internal guide'); + await expect.poll(() => page.evaluate(() => Array.from(window.__desktopFiles.entries()) + .find(([path]) => path.includes('/Linked Workspace/Welcome to Linked Workspace--'))?.[1] + )).toBe('# Edited internal guide'); +}); + +test('guide is seeded once in existing desktop workspaces and deletion is respected', async ({ page }) => { + // Native capabilities with browser-backed persistence survive page reloads. + await page.addInitScript(() => { + window.NL_PORT = 1; + window.Neutralino = {filesystem:{readFile:async()=>'',writeFile:async()=>true},os:{}}; + if (!localStorage.getItem('markdownViewerTabs')) { + localStorage.setItem('markdownViewerTabs', JSON.stringify([{ + id:'existing-document', title:'Existing document', content:'# existing', + workspaceId:'workspace_default', isOpen:true, createdAt:1, lastOpenedAt:1 + }])); + localStorage.setItem('markdownViewerActiveTab', 'existing-document'); + } + }); + await openApp(page); + const guide = page.locator('#document-tree [data-tree-type="document"]').filter({hasText:'Welcome to Linked Workspace'}); + await expect(guide).toHaveCount(1); + await page.reload(); + await page.waitForFunction(() => document.documentElement.dataset.appReady === 'true'); + await expect(guide).toHaveCount(1); + await guide.dispatchEvent('contextmenu',{clientX:120,clientY:120}); + await page.locator('.document-menu-context.open').getByRole('menuitem',{name:'Delete',exact:true}).click(); + await expect(guide).toHaveCount(0); + await page.waitForTimeout(700); + await page.reload(); + await page.waitForFunction(() => document.documentElement.dataset.appReady === 'true'); + await expect(guide).toHaveCount(0); +}); + +test('web does not seed a desktop Linked Workspace guide', async ({ page }) => { + await openApp(page); + await expect(page.locator('#document-tree [data-tree-type="linked-workspace"]')).toHaveCount(0); + await expect(page.locator('#document-tree')).not.toContainText('Welcome to Linked Workspace'); +}); diff --git a/workspace-storage.js b/workspace-storage.js index 566b5b38..85671e9b 100644 --- a/workspace-storage.js +++ b/workspace-storage.js @@ -1121,7 +1121,7 @@ } async _desktopDocumentRelativePath(tab, organization) { - const linked = Boolean(tab.sourcePath) || tab.kind === 'linked-recovery'; + const linked = Boolean(tab.sourcePath) || tab.kind === 'linked-recovery' || tab.kind === 'linked-workspace-guide'; const segments = linked ? ['Linked Workspace'] : ['Workspace'].concat(await this._desktopFolderSegments(tab, organization)); requireDocumentId(tab && tab.id); const suffix = await stableDocumentIdSuffix(tab.id); From 7910a12994d10e5d7e8c3d8ab35dac98c809bb41 Mon Sep 17 00:00:00 2001 From: Ioannis Kechagias Date: Sat, 3 Oct 2026 22:43:11 +0300 Subject: [PATCH 18/20] test(desktop): validate linked workflow without custom runtime hooks --- desktop-app/README.md | 7 +- desktop-app/resources/index.html | 8 +-- desktop-app/resources/js/script.js | 2 +- index.html | 8 +-- script.js | 2 +- tests/e2e/desktop-linked-file.spec.js | 7 +- .../desktop-linked-recovery-storage.spec.js | 8 ++- tests/e2e/linked-focus-menu.spec.js | 3 +- tests/e2e/web-desktop-isolation.spec.js | 66 +++++++++++++++++++ 9 files changed, 93 insertions(+), 18 deletions(-) create mode 100644 tests/e2e/web-desktop-isolation.spec.js diff --git a/desktop-app/README.md b/desktop-app/README.md index 6f4b15e8..b4c9cac7 100644 --- a/desktop-app/README.md +++ b/desktop-app/README.md @@ -36,7 +36,12 @@ Desktop-only files: - A Markdown file passed as a launch argument is loaded into the editor. - Files opened from disk can remain linked to their original path. Workspace (Vault) copies are separate documents; converting a linked file to a copy leaves its source untouched. - Linked files keep an app-local draft, but write to the original only on Ctrl+S. Reopening the same path selects its existing document rather than making another copy. -- **Link a Markdown folder** adds its Markdown files under Linked Locations. Files added or changed outside the app are detected while the desktop app is running; a locally edited draft is not silently overwritten by an external change. +- **Link a Markdown folder** adds its Markdown files under Linked Workspace, alongside Workspace (Vault) and Secret Workspace. Nested folders can be expanded independently; source paths appear on hover. Files added or changed outside the app are detected while the desktop app is running; a locally edited draft is not silently overwritten by an external change. +- Linked recovery drafts are stored separately under `Markdown Viewer Vault/Linked Workspace`; normal copies remain under `Markdown Viewer Vault/Workspace`. Existing linked drafts migrate safely. The default Linked Workspace guide is editable and is not recreated after intentional deletion. +- Converted Vault copies stay independent even when the original is reopened. Explicit **Rescan folder** or re-adding the folder restores removed links without replacing those copies; background scans respect removals. +- Linked context menus offer **Save** and **Save As**. Save updates the current source; Save As creates a separate external file and link. Vault Save As (or Ctrl+S) preserves the Vault document and focuses the external linked copy. Dialogs propose the document title and add `.md` when no extension is given. Ctrl+S also works with non-Latin keyboard layouts; Ctrl+Shift+S retains scroll-sync behavior. +- Dragging a linked document onto the Vault root or a Vault folder converts it into an independent copy there. The original is unchanged. Secret Workspace and reverse Vault-to-linked drops are not supported. Ordinary Vault moves use the existing HTML drag-and-drop behavior. +- Native linked controls and Save/Save As actions are desktop-only. Browser/Docker users retain normal stored documents and Markdown downloads. Desktop workflows are validated on Windows; macOS/Linux have not been manually verified. No custom Neutralino runtime is required by this feature. - Removing a link removes the app's reference, not the original file or folder. Native file drops are not covered by the linked-file flow in this build. - The app asks before closing the window. - Prepared desktop resources load dynamic libraries from local `/libs/...` paths after setup. diff --git a/desktop-app/resources/index.html b/desktop-app/resources/index.html index 1e161f3e..3c869179 100644 --- a/desktop-app/resources/index.html +++ b/desktop-app/resources/index.html @@ -73,8 +73,8 @@

Markdown Viewer - Online Markd @@ -349,10 +349,10 @@

Explorer