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/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/README.md b/desktop-app/README.md index a0a78103..b4c9cac7 100644 --- a/desktop-app/README.md +++ b/desktop-app/README.md @@ -34,6 +34,15 @@ 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 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. - 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 +90,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/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..66e172f3 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 0484e8c7..f3f2ccf7 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'; @@ -44,6 +45,20 @@ 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); + } + + 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; @@ -361,6 +376,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 +4224,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 +4252,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 +4297,7 @@ document.addEventListener("DOMContentLoaded", async function () { workspaces: [ { id: DEFAULT_WORKSPACE_ID, - name: 'Workspace', + name: hasDesktopFileAccess() ? 'Workspace (Vault)' : 'Workspace', expanded: true, createdAt: 0 }, @@ -4289,8 +4309,10 @@ document.addEventListener("DOMContentLoaded", async function () { } ], folders: [], + linkedLocations: [], ui: { filter: 'all', + linkedWorkspaceExpanded: true, collapsed: false, width: 288, lastWorkspaceId: DEFAULT_WORKSPACE_ID, @@ -4349,6 +4371,30 @@ 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, + 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))), + 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,8 +4406,11 @@ document.addEventListener("DOMContentLoaded", async function () { version: DOCUMENT_ORGANIZATION_VERSION, workspaces: workspaces, folders: folders, + linkedLocations: linkedLocations, 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, @@ -4417,6 +4466,19 @@ 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, + collapsedFolderPaths: Array.isArray(location.collapsedFolderPaths) ? location.collapsedFolderPaths.slice() : [], + 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 @@ -5715,6 +5777,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'); @@ -5722,6 +5808,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) { @@ -5849,11 +5936,35 @@ 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); } }, + { 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 }, + { 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 linked file', danger: true, 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); } }]; if (!isTemporaryDocument(tab)) { + 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); } }); actions.push({ id: 'favorite', @@ -5929,6 +6040,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 []; } @@ -6054,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', @@ -6203,8 +6322,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' }); @@ -6262,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); @@ -6517,7 +6636,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); @@ -6527,11 +6646,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(isLinkedDocument) && location.workspaceId !== DEFAULT_WORKSPACE_ID) return; + for (const tab of documents) { + if (isLinkedDocument(tab)) 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'; @@ -6550,6 +6677,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); @@ -6580,7 +6708,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'); @@ -6625,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); @@ -6671,9 +6799,11 @@ document.addEventListener("DOMContentLoaded", async function () { type: 'document', id: tab.id, documentId: tab.id, + disableDrag: tab.kind === LINKED_WORKSPACE_GUIDE_KIND, label: tab.title || 'Untitled', - ariaLabel: (tab.id === activeTabId ? 'Active document, ' : 'Document, ') + (tab.title || 'Untitled') + (tab.favorite ? ', favorite' : ''), - icon: '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), @@ -6683,6 +6813,12 @@ document.addEventListener("DOMContentLoaded", async function () { openSidebarDocument(tab.id); } }); + if (isLinkedDocument(tab)) { + row.classList.add('is-linked-document'); + row.title = tab.sourcePath; + } + 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); @@ -6691,6 +6827,8 @@ 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); if (folder && workspace) return workspace.name + ' / ' + getFolderPath(folder.id); @@ -6699,10 +6837,19 @@ document.addEventListener("DOMContentLoaded", async function () { function getDocumentTabHoverTitle(tab) { const title = tab && tab.title ? tab.title : 'Untitled'; + if (isLinkedDocument(tab)) return title + ' — Linked file: ' + tab.sourcePath; const folder = tab ? getFolderById(tab.folderId) : null; return folder ? getFolderPath(folder.id) + ' / ' + title : title; } + if (hasDesktopFileAccess()) { + document.querySelectorAll('.desktop-linked-command').forEach(function(element) { + element.hidden = false; + }); + const importLabel = document.querySelector('#import-from-file .app-menu-label'); + if (importLabel) importLabel.textContent = 'Import copies'; + } + function documentMatchesSidebarSearch(tab) { if (!documentSidebarSearch) return true; const haystack = ((tab.title || '') + ' ' + getDocumentLocationLabel(tab)).toLocaleLowerCase(); @@ -6726,7 +6873,150 @@ document.addEventListener("DOMContentLoaded", async function () { } let renderedDocuments = 0; documents.forEach(function(tab) { - if (appendDocumentTreeItem(tree, tab, 0, getDocumentLocationLabel(tab))) renderedDocuments++; + const meta = isLinkedDocument(tab) ? getLinkedDocumentMeta(tab) : getDocumentLocationLabel(tab); + if (appendDocumentTreeItem(tree, tab, 0, meta)) renderedDocuments++; + }); + return renderedDocuments; + } + + function getLinkedDocumentMeta(tab) { + if (tab.sourceMissing) return 'Missing source'; + if (tab.sourceConflict) return 'Conflict'; + return ''; + } + + function getLinkedLocationMenuActions(location) { + return [ + { 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); } } + ]; + } + + function renderLinkedLocationsTree(tree) { + if (!hasDesktopFileAccess()) return 0; + const linkedTabs = tabs.filter(function(tab) { + return !isTemporaryDocument(tab) && isLinkedWorkspaceDocument(tab); + }); + const locations = Array.isArray(documentOrganization.linkedLocations) ? documentOrganization.linkedLocations : []; + let renderedDocuments = 0; + 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: isLinkedWorkspaceDocument(activeDocument), + 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 || ''); }); + individualTabs.forEach(function(tab) { + if (appendDocumentTreeItem(workspaceGroup, tab, 1, getLinkedDocumentMeta(tab))) renderedDocuments++; + }); + + 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 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: 1, + currentLocation: Boolean(activeDocument && activeDocument.linkedRootId === location.id), + expanded: expanded, + meta: location.unavailable ? 'Unavailable' : String(locationTabs.length), + menuActions: getLinkedLocationMenuActions(location), + onToggle: toggleLocation, + onActivate: toggleLocation + }); + workspaceGroup.appendChild(rootRow); + + const rootGroup = document.createElement('div'); + rootGroup.className = 'document-tree-group document-tree-group--folder'; + 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 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 + ':' + folderPath, + label: folderName, + icon: 'lucide-folder-symlink', + depth: depth, + expanded: folderExpanded, + meta: '', + 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); + }); + 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, 2, []); + workspaceGroup.appendChild(rootGroup); }); return renderedDocuments; } @@ -6750,9 +7040,10 @@ document.addEventListener("DOMContentLoaded", async function () { 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.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); @@ -6783,6 +7074,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 && !isLinkedWorkspaceDocument(tab) && tab.workspaceId === workspace.id; }), expanded: expanded, meta: secretLocked ? 'Locked' : String(workspaceDocuments.length), locked: secretLocked, @@ -6960,6 +7252,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; @@ -7060,11 +7354,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 (hasDesktopFileAccess()) { + 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) { @@ -7229,6 +7550,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'); @@ -7240,6 +7563,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); @@ -10866,9 +11191,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 = isLinkedDocument(activeDocument) ? 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]; @@ -10877,6 +11209,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'); } @@ -11097,6 +11431,487 @@ 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) { + if (!hasDesktopFileAccess()) return null; + 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; + + // 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); + const draftHash = await hashLinkedSourceContent(tab.content); + const hasLocalDraft = Boolean(tab.sourceContentHash && draftHash && draftHash !== tab.sourceContentHash); + if (!hasLocalDraft && tab.content !== incomingContent) { + 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; + if (settings.open !== false) tab.lastOpenedAt = Date.now(); + } else { + tab = createTab(incomingContent, title || 'Untitled', 'split', { workspaceId: DEFAULT_WORKSPACE_ID, folderId: null }); + 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(); + } + if (!settings.deferUI) { + 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); + } + 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' + }); + } + 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 (!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); + } catch (_) { + tab.sourceMissing = true; + tab.sourceConflict = false; + saveTabsToStorage(tabs, [tab.id]); + refreshUI(); + 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]); + refreshUI(); + 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(); + } + if (!settings.background) renderTabBar(tabs, activeTabId); + refreshUI(); + return true; + } + + async function reconcileLinkedLocation(locationId, options) { + 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); + location.unavailable = false; + } catch (error) { + location.unavailable = true; + saveDocumentOrganization(); + refreshUI(); + 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; + 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, + deferUI: true, + 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); + refreshUI(); + 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) { + if (!hasDesktopFileAccess()) return null; + 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), restoreExcluded: true }); + 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() { + if (!hasDesktopFileAccess()) return 0; + 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() { + if (!hasDesktopFileAccess()) return; + 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 (!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 (!isLinkedDocument(tab)) 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(); + } + delete tab.sourcePath; + delete tab.sourceContentHash; + delete tab.sourceKind; + delete tab.linkedRootId; + delete tab.sourceRelativePath; + delete tab.sourceMissing; + delete tab.sourceConflict; + setDocumentLocation(tab, DEFAULT_WORKSPACE_ID, destination && destination.folderId || null); + tab.isOpen = true; + 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(); + 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' + }); + return true; + } + + 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 || !hasDesktopFileAccess() || !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, { 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, { background: true }); + } + } + }, 350)); + } + + async function initializeLinkedSourceMonitoring() { + if (linkedSourceMonitoringReady || !hasDesktopFileAccess()) 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); } @@ -11628,7 +12443,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' : (isLinkedDocument(tab) ? 'lucide-file-symlink' : 'lucide-file-text')) + ' tab-file-icon'; fileIcon.setAttribute('aria-hidden', 'true'); } @@ -12545,14 +13360,9 @@ 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 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); } @@ -12735,21 +13545,47 @@ document.addEventListener("DOMContentLoaded", async function () { const hadExistingWorkspace = tabs.length > 0; activeTabId = loadActiveTabId(); - // 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); + // 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); + } + + // 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; + 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'); - 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; @@ -12854,14 +13690,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() { @@ -14881,7 +15712,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); @@ -24059,29 +24890,124 @@ ${selector} .arrowheadPath { updateStlThemes(); }); - async function nativeSaveMarkdown() { + async function writeLinkedMarkdownSource(tab, content) { + if (!isLinkedDocument(tab)) return false; + let diskContent = null; try { - const content = markdownEditor.value; - const result = await Neutralino.os.showSaveDialog("Save Markdown File", { - filters: [ - { name: "Markdown files (*.md)", extensions: ["md", "markdown"] }, - { name: "All files (*.*)", extensions: ["*"] } - ] - }); + 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', + icon: 'lucide-check', + title: 'Original file updated' + }); + return true; + } + + 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: [ + { 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 (!hasDesktopFileAccess() || nativeMarkdownSaveInProgress) return; + nativeMarkdownSaveInProgress = true; + try { + const settings = options || {}; + saveCurrentTabState(); + 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 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, ""); - const activeTab = tabs.find(function(t) { return t.id === activeTabId; }); - if (activeTab) { - activeTab.title = fileName; - activeTab.content = content; - saveTabsToStorage(tabs, [activeTab.id]); - renderTabBar(tabs, activeTabId); + 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; } } @@ -24149,6 +25075,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(); @@ -24302,7 +25242,7 @@ ${selector} .arrowheadPath { if (!hasActiveOpenDocument()) return; if (isReleaseNotesActive()) return; if (blockShareSnapshotSourceAccess()) return; - if (typeof Neutralino !== 'undefined') { + if (hasDesktopFileAccess()) { nativeSaveMarkdown(); return; } @@ -29091,7 +30031,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') { @@ -29197,9 +30137,10 @@ ${selector} .arrowheadPath { } } - if (isCmdOrCtrl && !e.shiftKey && key === 's') { + if (isCmdOrCtrl && !e.shiftKey && !e.altKey && !e.isComposing && (key === 's' || e.code === 'KeyS')) { e.preventDefault(); - exportMd.click(); + if (hasDesktopFileAccess()) 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..85671e9b 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; } @@ -352,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(); @@ -521,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); @@ -576,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) @@ -605,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); @@ -619,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; @@ -645,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 { @@ -1095,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' || 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); segments.push(sanitizePathSegment(tab.title, 'Untitled') + '--' + suffix + '.md'); @@ -1103,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; @@ -1290,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; @@ -2841,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/desktop-app/resources/styles.css b/desktop-app/resources/styles.css index 478aac7d..15fecfa0 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,6 +751,16 @@ body { font-size: var(--ui-font-xs); } +.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); @@ -1326,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/index.html b/index.html index bd610001..d246a9f9 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 0484e8c7..f3f2ccf7 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'; @@ -44,6 +45,20 @@ 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); + } + + 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; @@ -361,6 +376,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 +4224,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 +4252,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 +4297,7 @@ document.addEventListener("DOMContentLoaded", async function () { workspaces: [ { id: DEFAULT_WORKSPACE_ID, - name: 'Workspace', + name: hasDesktopFileAccess() ? 'Workspace (Vault)' : 'Workspace', expanded: true, createdAt: 0 }, @@ -4289,8 +4309,10 @@ document.addEventListener("DOMContentLoaded", async function () { } ], folders: [], + linkedLocations: [], ui: { filter: 'all', + linkedWorkspaceExpanded: true, collapsed: false, width: 288, lastWorkspaceId: DEFAULT_WORKSPACE_ID, @@ -4349,6 +4371,30 @@ 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, + 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))), + 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,8 +4406,11 @@ document.addEventListener("DOMContentLoaded", async function () { version: DOCUMENT_ORGANIZATION_VERSION, workspaces: workspaces, folders: folders, + linkedLocations: linkedLocations, 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, @@ -4417,6 +4466,19 @@ 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, + collapsedFolderPaths: Array.isArray(location.collapsedFolderPaths) ? location.collapsedFolderPaths.slice() : [], + 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 @@ -5715,6 +5777,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'); @@ -5722,6 +5808,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) { @@ -5849,11 +5936,35 @@ 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); } }, + { 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 }, + { 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 linked file', danger: true, 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); } }]; if (!isTemporaryDocument(tab)) { + 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); } }); actions.push({ id: 'favorite', @@ -5929,6 +6040,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 []; } @@ -6054,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', @@ -6203,8 +6322,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' }); @@ -6262,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); @@ -6517,7 +6636,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); @@ -6527,11 +6646,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(isLinkedDocument) && location.workspaceId !== DEFAULT_WORKSPACE_ID) return; + for (const tab of documents) { + if (isLinkedDocument(tab)) 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'; @@ -6550,6 +6677,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); @@ -6580,7 +6708,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'); @@ -6625,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); @@ -6671,9 +6799,11 @@ document.addEventListener("DOMContentLoaded", async function () { type: 'document', id: tab.id, documentId: tab.id, + disableDrag: tab.kind === LINKED_WORKSPACE_GUIDE_KIND, label: tab.title || 'Untitled', - ariaLabel: (tab.id === activeTabId ? 'Active document, ' : 'Document, ') + (tab.title || 'Untitled') + (tab.favorite ? ', favorite' : ''), - icon: '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), @@ -6683,6 +6813,12 @@ document.addEventListener("DOMContentLoaded", async function () { openSidebarDocument(tab.id); } }); + if (isLinkedDocument(tab)) { + row.classList.add('is-linked-document'); + row.title = tab.sourcePath; + } + 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); @@ -6691,6 +6827,8 @@ 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); if (folder && workspace) return workspace.name + ' / ' + getFolderPath(folder.id); @@ -6699,10 +6837,19 @@ document.addEventListener("DOMContentLoaded", async function () { function getDocumentTabHoverTitle(tab) { const title = tab && tab.title ? tab.title : 'Untitled'; + if (isLinkedDocument(tab)) return title + ' — Linked file: ' + tab.sourcePath; const folder = tab ? getFolderById(tab.folderId) : null; return folder ? getFolderPath(folder.id) + ' / ' + title : title; } + if (hasDesktopFileAccess()) { + document.querySelectorAll('.desktop-linked-command').forEach(function(element) { + element.hidden = false; + }); + const importLabel = document.querySelector('#import-from-file .app-menu-label'); + if (importLabel) importLabel.textContent = 'Import copies'; + } + function documentMatchesSidebarSearch(tab) { if (!documentSidebarSearch) return true; const haystack = ((tab.title || '') + ' ' + getDocumentLocationLabel(tab)).toLocaleLowerCase(); @@ -6726,7 +6873,150 @@ document.addEventListener("DOMContentLoaded", async function () { } let renderedDocuments = 0; documents.forEach(function(tab) { - if (appendDocumentTreeItem(tree, tab, 0, getDocumentLocationLabel(tab))) renderedDocuments++; + const meta = isLinkedDocument(tab) ? getLinkedDocumentMeta(tab) : getDocumentLocationLabel(tab); + if (appendDocumentTreeItem(tree, tab, 0, meta)) renderedDocuments++; + }); + return renderedDocuments; + } + + function getLinkedDocumentMeta(tab) { + if (tab.sourceMissing) return 'Missing source'; + if (tab.sourceConflict) return 'Conflict'; + return ''; + } + + function getLinkedLocationMenuActions(location) { + return [ + { 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); } } + ]; + } + + function renderLinkedLocationsTree(tree) { + if (!hasDesktopFileAccess()) return 0; + const linkedTabs = tabs.filter(function(tab) { + return !isTemporaryDocument(tab) && isLinkedWorkspaceDocument(tab); + }); + const locations = Array.isArray(documentOrganization.linkedLocations) ? documentOrganization.linkedLocations : []; + let renderedDocuments = 0; + 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: isLinkedWorkspaceDocument(activeDocument), + 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 || ''); }); + individualTabs.forEach(function(tab) { + if (appendDocumentTreeItem(workspaceGroup, tab, 1, getLinkedDocumentMeta(tab))) renderedDocuments++; + }); + + 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 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: 1, + currentLocation: Boolean(activeDocument && activeDocument.linkedRootId === location.id), + expanded: expanded, + meta: location.unavailable ? 'Unavailable' : String(locationTabs.length), + menuActions: getLinkedLocationMenuActions(location), + onToggle: toggleLocation, + onActivate: toggleLocation + }); + workspaceGroup.appendChild(rootRow); + + const rootGroup = document.createElement('div'); + rootGroup.className = 'document-tree-group document-tree-group--folder'; + 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 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 + ':' + folderPath, + label: folderName, + icon: 'lucide-folder-symlink', + depth: depth, + expanded: folderExpanded, + meta: '', + 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); + }); + 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, 2, []); + workspaceGroup.appendChild(rootGroup); }); return renderedDocuments; } @@ -6750,9 +7040,10 @@ document.addEventListener("DOMContentLoaded", async function () { 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.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); @@ -6783,6 +7074,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 && !isLinkedWorkspaceDocument(tab) && tab.workspaceId === workspace.id; }), expanded: expanded, meta: secretLocked ? 'Locked' : String(workspaceDocuments.length), locked: secretLocked, @@ -6960,6 +7252,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; @@ -7060,11 +7354,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 (hasDesktopFileAccess()) { + 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) { @@ -7229,6 +7550,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'); @@ -7240,6 +7563,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); @@ -10866,9 +11191,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 = isLinkedDocument(activeDocument) ? 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]; @@ -10877,6 +11209,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'); } @@ -11097,6 +11431,487 @@ 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) { + if (!hasDesktopFileAccess()) return null; + 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; + + // 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); + const draftHash = await hashLinkedSourceContent(tab.content); + const hasLocalDraft = Boolean(tab.sourceContentHash && draftHash && draftHash !== tab.sourceContentHash); + if (!hasLocalDraft && tab.content !== incomingContent) { + 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; + if (settings.open !== false) tab.lastOpenedAt = Date.now(); + } else { + tab = createTab(incomingContent, title || 'Untitled', 'split', { workspaceId: DEFAULT_WORKSPACE_ID, folderId: null }); + 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(); + } + if (!settings.deferUI) { + 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); + } + 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' + }); + } + 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 (!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); + } catch (_) { + tab.sourceMissing = true; + tab.sourceConflict = false; + saveTabsToStorage(tabs, [tab.id]); + refreshUI(); + 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]); + refreshUI(); + 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(); + } + if (!settings.background) renderTabBar(tabs, activeTabId); + refreshUI(); + return true; + } + + async function reconcileLinkedLocation(locationId, options) { + 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); + location.unavailable = false; + } catch (error) { + location.unavailable = true; + saveDocumentOrganization(); + refreshUI(); + 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; + 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, + deferUI: true, + 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); + refreshUI(); + 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) { + if (!hasDesktopFileAccess()) return null; + 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), restoreExcluded: true }); + 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() { + if (!hasDesktopFileAccess()) return 0; + 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() { + if (!hasDesktopFileAccess()) return; + 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 (!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 (!isLinkedDocument(tab)) 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(); + } + delete tab.sourcePath; + delete tab.sourceContentHash; + delete tab.sourceKind; + delete tab.linkedRootId; + delete tab.sourceRelativePath; + delete tab.sourceMissing; + delete tab.sourceConflict; + setDocumentLocation(tab, DEFAULT_WORKSPACE_ID, destination && destination.folderId || null); + tab.isOpen = true; + 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(); + 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' + }); + return true; + } + + 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 || !hasDesktopFileAccess() || !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, { 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, { background: true }); + } + } + }, 350)); + } + + async function initializeLinkedSourceMonitoring() { + if (linkedSourceMonitoringReady || !hasDesktopFileAccess()) 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); } @@ -11628,7 +12443,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' : (isLinkedDocument(tab) ? 'lucide-file-symlink' : 'lucide-file-text')) + ' tab-file-icon'; fileIcon.setAttribute('aria-hidden', 'true'); } @@ -12545,14 +13360,9 @@ 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 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); } @@ -12735,21 +13545,47 @@ document.addEventListener("DOMContentLoaded", async function () { const hadExistingWorkspace = tabs.length > 0; activeTabId = loadActiveTabId(); - // 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); + // 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); + } + + // 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; + 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'); - 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; @@ -12854,14 +13690,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() { @@ -14881,7 +15712,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); @@ -24059,29 +24890,124 @@ ${selector} .arrowheadPath { updateStlThemes(); }); - async function nativeSaveMarkdown() { + async function writeLinkedMarkdownSource(tab, content) { + if (!isLinkedDocument(tab)) return false; + let diskContent = null; try { - const content = markdownEditor.value; - const result = await Neutralino.os.showSaveDialog("Save Markdown File", { - filters: [ - { name: "Markdown files (*.md)", extensions: ["md", "markdown"] }, - { name: "All files (*.*)", extensions: ["*"] } - ] - }); + 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', + icon: 'lucide-check', + title: 'Original file updated' + }); + return true; + } + + 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: [ + { 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 (!hasDesktopFileAccess() || nativeMarkdownSaveInProgress) return; + nativeMarkdownSaveInProgress = true; + try { + const settings = options || {}; + saveCurrentTabState(); + 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 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, ""); - const activeTab = tabs.find(function(t) { return t.id === activeTabId; }); - if (activeTab) { - activeTab.title = fileName; - activeTab.content = content; - saveTabsToStorage(tabs, [activeTab.id]); - renderTabBar(tabs, activeTabId); + 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; } } @@ -24149,6 +25075,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(); @@ -24302,7 +25242,7 @@ ${selector} .arrowheadPath { if (!hasActiveOpenDocument()) return; if (isReleaseNotesActive()) return; if (blockShareSnapshotSourceAccess()) return; - if (typeof Neutralino !== 'undefined') { + if (hasDesktopFileAccess()) { nativeSaveMarkdown(); return; } @@ -29091,7 +30031,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') { @@ -29197,9 +30137,10 @@ ${selector} .arrowheadPath { } } - if (isCmdOrCtrl && !e.shiftKey && key === 's') { + if (isCmdOrCtrl && !e.shiftKey && !e.altKey && !e.isComposing && (key === 's' || e.code === 'KeyS')) { e.preventDefault(); - exportMd.click(); + if (hasDesktopFileAccess()) nativeSaveMarkdown({ useLinkedSource: true }); + else exportMd.click(); } if (isCmdOrCtrl && key === 'c') { const activeEl = document.activeElement; diff --git a/styles.css b/styles.css index 478aac7d..15fecfa0 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,6 +751,16 @@ body { font-size: var(--ui-font-xs); } +.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); @@ -1326,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/tests/e2e/desktop-linked-file.spec.js b/tests/e2e/desktop-linked-file.spec.js new file mode 100644 index 00000000..bdf18a18 --- /dev/null +++ b/tests/e2e/desktop-linked-file.spec.js @@ -0,0 +1,307 @@ +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) => ({ + 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 () => { + 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'); + 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], + writeFile: async () => true, + 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 Workspace'); + 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(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, + writeFile: async () => true, + createWatcher: async () => 1, + removeWatcher: async () => 1 + }, + events: { on: async () => ({ success: true }) }, + storage: { setData: async () => ({ success: true }) }, + os: {} + }; + 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 () => { + 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(() => { + window.__original = '# original'; + window.__linkedWrites = []; + window.Neutralino = { + filesystem: { + readFile: async () => window.__original, + writeFile: async () => true, + 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).toHaveClass(/is-linked-document/); + 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 linked file' }).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('desktop link commands have separate available 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')).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-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 + }))); + expect(icons.every(icon => icon.mask !== 'none' || icon.webkitMask !== 'none')).toBeTruthy(); +}); 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..cfae24ad --- /dev/null +++ b/tests/e2e/desktop-linked-recovery-storage.spec.js @@ -0,0 +1,190 @@ +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); + }; + Neutralino.os.showFolderDialog = async () => 'C:/Original'; + document.getElementById('sidebar-link-folder').click(); + 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.locator('#sidebar-link-folder').click(); + } + 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'); + Neutralino.os.showFolderDialog = async () => 'C:/Original'; + document.getElementById('sidebar-link-folder').click(); + 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', '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); + 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-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/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..da90f0df 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(); diff --git a/tests/e2e/linked-focus-menu.spec.js b/tests/e2e/linked-focus-menu.spec.js new file mode 100644 index 00000000..7b79a860 --- /dev/null +++ b/tests/e2e/linked-focus-menu.spec.js @@ -0,0 +1,65 @@ +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'); + Neutralino.os.showFolderDialog = async () => 'C:/Notes'; + document.getElementById('sidebar-link-folder').click(); + 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/); +}); 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/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'); +}); 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); diff --git a/tests/e2e/web-desktop-isolation.spec.js b/tests/e2e/web-desktop-isolation.spec.js new file mode 100644 index 00000000..9183e016 --- /dev/null +++ b/tests/e2e/web-desktop-isolation.spec.js @@ -0,0 +1,66 @@ +const { test, expect } = require('@playwright/test'); +const { openApp } = require('../helpers/app'); +const { installDesktopFilesystem } = require('../helpers/desktop-filesystem'); + +for (const partialRuntime of [false, true]) { + test(`web keeps linked recovery documents usable without native controls (${partialRuntime ? 'partial library' : 'no runtime'})`, async ({ page }) => { + await page.addInitScript(partial => { + if (partial) { window.Neutralino = {}; window.NL_PORT = 1; } + localStorage.setItem('markdownViewerTabs', JSON.stringify([{ + id: 'recovered-linked', title: 'Recovered', content: '# recovery draft', + sourcePath: 'C:/Original/recovered.md', sourceMissing: true, sourceConflict: true, + favorite: true, isOpen: true, workspaceId: 'workspace_default', + createdAt: 1, lastOpenedAt: 1, lastEditedAt: 1 + }])); + }, partialRuntime); + await openApp(page); + const tree = page.locator('#document-tree'); + await expect(tree).not.toContainText('Workspace (Vault)'); + const row = tree.locator('[data-document-id="recovered-linked"]'); + await expect(tree.locator('[data-tree-type="linked-workspace"]')).toHaveCount(0); + for (const filter of ['all', 'recent', 'favorites']) { + await page.locator(`[data-document-filter="${filter}"]`).click(); + await expect(row).toBeVisible(); + await expect(row).not.toHaveClass(/is-linked-document|is-source-missing|is-source-conflict/); + await expect(row.locator('.lucide-file-text')).toHaveCount(1); + await expect(row.locator('.document-tree-main')).toHaveAttribute('title','Recovered'); + await expect(row).toHaveAttribute('draggable','true'); + await row.dispatchEvent('contextmenu', {clientX:120,clientY:120}); + const menu = page.locator('.document-menu-context.open'); + for (const action of ['Save','Save As…','Reload from disk','Open containing folder','Convert to Workspace copy','Remove linked file']) { + await expect(menu.getByRole('menuitem',{name:action,exact:true})).toHaveCount(0); + } + await expect(menu.getByRole('menuitem',{name:'Download Markdown',exact:true})).toBeVisible(); + await page.keyboard.press('Escape'); + } + await expect(page.locator('#sidebar-link-files')).toBeHidden(); + await expect(page.locator('#sidebar-link-folder')).toBeHidden(); + await row.locator('.document-tree-main').click(); + await expect(page.locator('#markdown-editor')).toHaveValue('# recovery draft'); + await expect(page.locator('#save-status')).not.toContainText('updates original'); + const download = page.waitForEvent('download'); + await page.locator('#markdown-editor').dispatchEvent('keydown',{key:'σ',code:'KeyS',ctrlKey:true}); + expect((await download).suggestedFilename()).toBe('Recovered.md'); + await page.evaluate(async () => { + await window.NL_IMPORT_EXTERNAL_FILE('# ignored','Ignored','C:/Ignored.md'); + }); + await expect(tree.locator('[data-tree-type="document"]')).toHaveCount(1); + await expect(page.locator('.app-toast').filter({hasText:'Save failed'})).toHaveCount(0); + }); +} + +test('standard desktop HTML dragging converts a linked document without the custom pointer layer', 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 row = page.locator('#document-tree [data-tree-type="document"]').filter({hasText:'notes'}); + const root = page.locator('#document-tree [data-tree-type="workspace"]').filter({hasText:'Workspace (Vault)'}); + await expect(row).toHaveAttribute('draggable','true'); + await row.locator('.document-tree-main').dragTo(root); + await expect(row).not.toHaveClass(/is-linked-document/); + await expect(row).toHaveAttribute('data-tree-depth','1'); + expect(await page.evaluate(() => window.__desktopFiles.get('C:/Original/notes.md'))).toBe('# original'); +}); diff --git a/tests/helpers/desktop-filesystem.js b/tests/helpers/desktop-filesystem.js new file mode 100644 index 00000000..80728661 --- /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 Object.assign(new Error('missing'), { code: 'NE_FS_NOPATHE' }); + }, + 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); +}); diff --git a/workspace-storage.js b/workspace-storage.js index df3eb067..85671e9b 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; } @@ -352,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(); @@ -521,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); @@ -576,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) @@ -605,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); @@ -619,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; @@ -645,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 { @@ -1095,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' || 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); segments.push(sanitizePathSegment(tab.title, 'Untitled') + '--' + suffix + '.md'); @@ -1103,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; @@ -1290,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; @@ -2841,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) ];