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 @@
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
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)
];