diff --git a/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md b/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md index 885b47e9..f84765f4 100644 --- a/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md +++ b/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md @@ -163,7 +163,7 @@ cargo test --manifest-path rust/Cargo.toml -p mnote-web --lib routes::web_shell 拆分项: -- [ ] C1. `sidebar-workspace-runtime.js`:workspace source switch、sidebar tabs、collapse/resize 状态。 +- [x] C1. `sidebar-workspace-runtime.js`:workspace source switch、sidebar tabs、collapse/resize 状态。 - [ ] C2. `sidebar-page-tree-runtime.js`:page tree row selection、rename title patch、breadcrumb/title sync。 - [ ] C3. `sidebar-filetree-open-runtime.js`:local markdown / resource open target、active resource tab dispatch。 - [ ] C4. `sidebar-filetree-command-runtime.js`:create/rename/delete/copy/move/trash/restore/purge command payload。 diff --git a/rust/crates/mnote-web/browser/sidebar-tree-runtime.js b/rust/crates/mnote-web/browser/sidebar-tree-runtime.js index 82dea3d2..258160e5 100644 --- a/rust/crates/mnote-web/browser/sidebar-tree-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-tree-runtime.js @@ -1,3 +1,5 @@ +import { createSidebarWorkspaceRuntime } from './sidebar-workspace-runtime.js'; + (function(){ if (window.__mnoteSidebarTreeRuntimeStarted) return; window.__mnoteSidebarTreeRuntimeStarted = true; @@ -620,979 +622,34 @@ } } - function normalizeSidebarTreeMode(value) { - var mode = String(value || '').trim(); - return mode === 'filetree' ? 'filetree' : 'page'; - } - - function readStoredSidebarTreeMode() { - var runtimeFn = sidebarShellRuntimeFunction('readStoredSidebarTreeMode'); - if (runtimeFn) return runtimeFn(); - var params = new URLSearchParams(window.location.search); - var fromUrl = normalizeSidebarTreeMode(params.get('treeView') || params.get('sidebarTree')); - if (params.has('treeView') || params.has('sidebarTree')) return fromUrl; - try { - var stored = window.sessionStorage ? window.sessionStorage.getItem(MNOTE_SIDEBAR_TREE_MODE_KEY) : ''; - return normalizeSidebarTreeMode(stored); - } catch (_) { - return 'page'; - } - } - - function persistSidebarTreeMode(mode) { - var runtimeFn = sidebarShellRuntimeFunction('persistSidebarTreeMode'); - if (runtimeFn) return runtimeFn(mode); - var normalized = normalizeSidebarTreeMode(mode); - document.documentElement.setAttribute('data-mnote-sidebar-tree-mode', normalized); - try { - if (window.sessionStorage) window.sessionStorage.setItem(MNOTE_SIDEBAR_TREE_MODE_KEY, normalized); - } catch (_) {} - return normalized; - } - - function activeSidebarTreeMode() { - var runtimeFn = sidebarShellRuntimeFunction('activeSidebarTreeMode'); - if (runtimeFn) return runtimeFn(); - var active = document.querySelector('[data-mnote-sidebar-tree-tab][aria-selected="true"]'); - if (active instanceof HTMLElement) { - return normalizeSidebarTreeMode(active.getAttribute('data-mnote-sidebar-tree-tab')); - } - return readStoredSidebarTreeMode(); - } - - function resolveWorkspaceId(trigger) { - var direct = (trigger.getAttribute('data-workspace-id') || '').trim(); - if (direct) return direct; - var root = trigger.closest('[data-workspace-id]'); - if (root) { - var value = (root.getAttribute('data-workspace-id') || '').trim(); - if (value) return value; - } - var documentId = currentDocumentId(); - if (documentId) { - var shell = document.querySelector('.document-shell[data-document-id="' + cssEscape(documentId) + '"][data-workspace-id]'); - if (shell instanceof HTMLElement) { - var shellWorkspaceId = (shell.getAttribute('data-workspace-id') || '').trim(); - if (shellWorkspaceId) return shellWorkspaceId; - } - } - var activePane = document.querySelector('[data-pane-visible="true"][data-pane-workspace-id]'); - if (activePane instanceof HTMLElement) { - var paneWorkspaceId = (activePane.getAttribute('data-pane-workspace-id') || '').trim(); - if (paneWorkspaceId) return paneWorkspaceId; - } - var anyDocumentShell = document.querySelector('.document-shell[data-workspace-id]'); - if (anyDocumentShell instanceof HTMLElement) { - var documentWorkspaceId = (anyDocumentShell.getAttribute('data-workspace-id') || '').trim(); - if (documentWorkspaceId) return documentWorkspaceId; - } - var fromDom = document.querySelector('[data-workspace-id]'); - if (fromDom instanceof HTMLElement) { - var domWorkspaceId = (fromDom.getAttribute('data-workspace-id') || '').trim(); - if (domWorkspaceId) return domWorkspaceId; - } - return (new URLSearchParams(window.location.search).get('workspaceId') || 'default').trim() || 'default'; - } - - function currentWorkspaceId() { - return resolveWorkspaceId(document.body); - } - - function currentSourceKind() { - var params = new URLSearchParams(window.location.search); - var fromUrl = (params.get('sourceKind') || '').trim(); - if (fromUrl) return fromUrl; - var fromBody = document.body instanceof HTMLElement ? (document.body.getAttribute('data-mnote-source-kind') || '').trim() : ''; - if (fromBody) return fromBody; - if ( - window.location.pathname === '/' && - !(params.get('workspaceId') || '').trim() && - !(params.get('rootUri') || '').trim() && - !(params.get('pageId') || '').trim() - ) { - return 'local_folder'; - } - return 'convex_workspace'; - } - - function currentRootUri() { - var params = new URLSearchParams(window.location.search); - var fromUrl = (params.get('rootUri') || '').trim(); - if (fromUrl) return fromUrl; - return document.body instanceof HTMLElement ? (document.body.getAttribute('data-mnote-root-uri') || '').trim() : ''; - } - - function rememberCloudWorkspaceId(workspaceId) { - var normalized = String(workspaceId || '').trim(); - if (!normalized || normalized === 'local-folder' || normalized === 'default' || normalized.indexOf('local-ws:') === 0) return; - try { - if (window.localStorage) window.localStorage.setItem(MNOTE_LAST_CLOUD_WORKSPACE_KEY, normalized); - } catch (_) {} - } - - function readCurrentCloudWorkspaceIdFromPage() { - if (currentSourceKind() === 'local_folder') return ''; - var params = new URLSearchParams(window.location.search); - var fromUrl = (params.get('workspaceId') || '').trim(); - if (fromUrl && fromUrl !== 'default' && fromUrl.indexOf('local-ws:') !== 0) return fromUrl; - var fromDom = document.querySelector('[data-workspace-id]'); - if (fromDom instanceof HTMLElement) { - var value = (fromDom.getAttribute('data-workspace-id') || '').trim(); - if (value && value !== 'local-folder' && value !== 'default' && value.indexOf('local-ws:') !== 0) return value; - } - return ''; - } - - function rememberCurrentCloudWorkspaceId() { - rememberCloudWorkspaceId(readCurrentCloudWorkspaceIdFromPage()); - } - - function readLastCloudWorkspaceId() { - try { - var stored = window.localStorage ? window.localStorage.getItem(MNOTE_LAST_CLOUD_WORKSPACE_KEY) : ''; - if (stored && stored.trim() && stored.trim() !== 'default' && stored.trim().indexOf('local-ws:') !== 0) return stored.trim(); - } catch (_) {} - var params = new URLSearchParams(window.location.search); - var fromUrl = (params.get('workspaceId') || '').trim(); - if (fromUrl && currentSourceKind() !== 'local_folder' && fromUrl.indexOf('local-ws:') !== 0) return fromUrl; - var fromDom = document.querySelector('[data-workspace-id]'); - if (fromDom instanceof HTMLElement) { - var value = (fromDom.getAttribute('data-workspace-id') || '').trim(); - if (value && value !== 'local-folder' && value.indexOf('local-ws:') !== 0) return value; - } - return ''; - } - - function copyWorkspaceSourceParams(targetUrl) { - var params = new URLSearchParams(window.location.search); - ['sourceKind', 'rootUri', 'secondaryDocumentId', 'secondarySourceKind', 'secondaryRootUri'].forEach(function(name) { - var value = (params.get(name) || '').trim(); - if (value) targetUrl.searchParams.set(name, value); - }); - var sourceKind = currentSourceKind(); - var rootUri = currentRootUri(); - if (sourceKind && !targetUrl.searchParams.get('sourceKind')) targetUrl.searchParams.set('sourceKind', sourceKind); - if (rootUri && !targetUrl.searchParams.get('rootUri')) targetUrl.searchParams.set('rootUri', rootUri); - } - - function currentWorkspaceSourcePayload() { - var params = new URLSearchParams(window.location.search); - var payload = {}; - ['sourceKind', 'rootUri'].forEach(function(name) { - var value = (params.get(name) || '').trim(); - if (value) payload[name] = value; - }); - var sourceKind = currentSourceKind(); - var rootUri = currentRootUri(); - if (sourceKind && !payload.sourceKind) payload.sourceKind = sourceKind; - if (rootUri && !payload.rootUri) payload.rootUri = rootUri; - return payload; - } - - function currentActorStorageId() { - var fromBody = document.body instanceof HTMLElement ? (document.body.getAttribute('data-mnote-actor-id') || '').trim() : ''; - if (fromBody && fromBody !== 'anonymous') return fromBody; - return ''; - } - - function recentLocalRootsStorageKey() { - var actorId = currentActorStorageId(); - return actorId ? MNOTE_RECENT_LOCAL_ROOTS_KEY_PREFIX + encodeURIComponent(actorId) : ''; - } - - function readRecentLocalRoots() { - try { - var storageKey = recentLocalRootsStorageKey(); - if (!storageKey) return []; - var raw = window.localStorage ? window.localStorage.getItem(storageKey) : ''; - var parsed = raw ? JSON.parse(raw) : []; - return Array.isArray(parsed) ? parsed.filter(function(value) { - return typeof value === 'string' && value.trim(); - }).slice(0, 10) : []; - } catch (_) { - return []; - } - } - - function rememberLocalRoot(rootUri) { - try { - if (!window.localStorage) return; - var storageKey = recentLocalRootsStorageKey(); - if (!storageKey) return; - var roots = readRecentLocalRoots().filter(function(value) { return value !== rootUri; }); - roots.unshift(rootUri); - window.localStorage.setItem(storageKey, JSON.stringify(roots.slice(0, 10))); - } catch (_) {} - } - - function pathToFileRootUri(value) { - var trimmed = String(value || '').trim(); - if (!trimmed) return ''; - if (/^file:\/\//i.test(trimmed)) return trimmed; - if (trimmed.charAt(0) !== '/') return ''; - return 'file://' + trimmed.split('/').map(function(part, index) { - return index === 0 ? '' : encodeURIComponent(part); - }).join('/'); - } - - function fileRootUriToPathInput(rootUri) { - var value = String(rootUri || '').replace(/^file:\/\//, ''); - try { - return decodeURIComponent(value); - } catch (_) { - return value; - } - } - - function recentLocalRootLabel(rootUri) { - var label = fileRootUriToPathInput(rootUri); - return label || '本地文件夹'; - } - - function normalizeGrantedLocalFolderRootUri(grant) { - var rootUri = String(grant && grant.rootUri || '').trim(); - if (rootUri && /^file:\/\//i.test(rootUri)) return rootUri; - var rootPath = String(grant && grant.rootPath || '').trim(); - if (rootPath) return pathToFileRootUri(rootPath); - if (rootUri) return pathToFileRootUri(rootUri); - return ''; - } - - function isDefaultWorkspaceAutoGrant(grant) { - var source = String(grant && grant.source || '').trim(); - var workspaceId = String(grant && grant.workspaceId || '').trim(); - var rootUri = String(grant && grant.rootUri || '').trim(); - var permission = String(grant && grant.permission || '').trim(); - var createdBy = String(grant && (grant.createdBy || grant.ownerUserId) || '').trim(); - var targetUser = String(grant && (grant.userId || grant.targetUserId) || '').trim(); - return source === 'auto' - && workspaceId - && permission === 'write' - && createdBy === targetUser - && /^local:\/\/users\/.+\/workspaces\/my-space$/.test(rootUri); - } - - function openLocalFolderRoot(rootUri) { - if (currentSourceKind() !== 'local_folder') { - rememberCurrentCloudWorkspaceId(); - } - rememberLocalRoot(rootUri); - var targetUrl = new URL('/', window.location.origin); - targetUrl.searchParams.set('treeView', 'filetree'); - targetUrl.searchParams.set('sourceKind', 'local_folder'); - targetUrl.searchParams.set('rootUri', rootUri); - window.location.href = targetUrl.toString(); - } - - function switchToCloudWorkspace() { - var targetUrl = new URL('/', window.location.origin); - targetUrl.searchParams.set('treeView', activeSidebarTreeMode() || 'filetree'); - targetUrl.searchParams.set('mnoteHome', '1'); - window.location.href = targetUrl.toString(); - } - - function closeTrashModal() { - var modal = document.querySelector('[data-testid="mnote-trash-modal"]'); - if (!modal) return; - var workbench = modal.querySelector('[data-testid="mnote-trash-workbench"]'); - if (workbench && workbench.__mnoteTrashEventSource && typeof workbench.__mnoteTrashEventSource.close === 'function') { - workbench.__mnoteTrashEventSource.close(); - } - var fileScrollTop = Number(modal.getAttribute('data-file-scroll-top') || '0'); - var fileRoot = document.getElementById('sidebar-file-tree-root'); - if (fileRoot instanceof HTMLElement && Number.isFinite(fileScrollTop)) fileRoot.scrollTop = fileScrollTop; - if (modal.parentElement) modal.parentElement.removeChild(modal); - document.documentElement.removeAttribute('data-mnote-trash-modal-open'); - } - - function executeTrashWorkbenchScripts(parsed) { - parsed.querySelectorAll('script').forEach(function(script) { - var nextScript = document.createElement('script'); - Array.from(script.attributes || []).forEach(function(attr) { - nextScript.setAttribute(attr.name, attr.value); - }); - nextScript.textContent = script.textContent || ''; - document.body.appendChild(nextScript); - if (nextScript.parentElement) nextScript.parentElement.removeChild(nextScript); - }); - } - - function renderLocalFolderTrashPlaceholder(content, rootUri) { - content.innerHTML = [ - '
', - '

本地文件夹垃圾箱

', - '

本地删除项保存在当前目录的 .mnote/trash.mnote/trash-index.json 中。

', - '
当前弹窗已保持在本地文件夹上下文:' + escapeHtml(rootUri || '未选择本地目录') + '
', - '
' - ].join(''); - } - - function openTrashModal(trigger) { - var existing = document.querySelector('[data-testid="mnote-trash-modal"]'); - if (existing) return; - var fileRoot = document.getElementById('sidebar-file-tree-root'); - var activeRow = document.querySelector('#sidebar-file-tree-root .tree-row[data-selected="true"], #sidebar-tree-root .tree-row[data-active="true"]'); - var overlay = document.createElement('div'); - overlay.className = 'mnote-trash-modal'; - overlay.setAttribute('data-testid', 'mnote-trash-modal'); - overlay.setAttribute('role', 'dialog'); - overlay.setAttribute('aria-modal', 'true'); - overlay.setAttribute('aria-label', '垃圾箱'); - overlay.setAttribute('data-file-scroll-top', fileRoot instanceof HTMLElement ? String(fileRoot.scrollTop) : '0'); - if (activeRow instanceof HTMLElement) { - overlay.setAttribute('data-active-row-id', activeRow.getAttribute('data-row-id') || activeRow.getAttribute('data-node-id') || ''); - } - overlay.innerHTML = '
' + - '
' + - '' + - '
正在加载垃圾箱...
' + - '
'; - document.body.appendChild(overlay); - document.documentElement.setAttribute('data-mnote-trash-modal-open', 'true'); - var content = overlay.querySelector('[data-testid="mnote-trash-modal-content"]'); - var sourceKind = currentSourceKind(); - var workspaceId = resolveWorkspaceId(trigger || document.body); - var url = new URL('/trash', window.location.origin); - if (sourceKind === 'local_folder') { - var rootUri = currentRootUri(); - if (!rootUri) { - renderLocalFolderTrashPlaceholder(content, ''); - return; - } - url.searchParams.set('sourceKind', 'local_folder'); - url.searchParams.set('rootUri', rootUri); - } else if (workspaceId) { - url.searchParams.set('workspaceId', workspaceId); - } - fetch(url.toString(), { headers: { 'x-mnote-trash-modal': '1' } }).then(function(response) { - return response.text().then(function(html) { - if (!response.ok) throw new Error('trash_modal_load_failed_' + response.status); - var parsed = new DOMParser().parseFromString(html, 'text/html'); - var workbench = parsed.querySelector('[data-testid="mnote-trash-workbench"]'); - if (!workbench) throw new Error('trash_modal_missing_workbench'); - content.innerHTML = ''; - content.appendChild(workbench); - executeTrashWorkbenchScripts(parsed); - }); - }).catch(function(error) { - content.innerHTML = '
' + escapeHtml(error && error.message ? error.message : String(error)) + '
'; - }); - } - - function closeLocalFolderDialog() { - var existing = document.querySelector('[data-testid="mnote-local-folder-dialog"]'); - if (existing && existing.parentElement) existing.parentElement.removeChild(existing); - } - - function readPathFromDirectoryFiles(files) { - var first = files && files.length ? files[0] : null; - if (!first) return ''; - var rawPath = typeof first.path === 'string' ? first.path : ''; - var relative = typeof first.webkitRelativePath === 'string' ? first.webkitRelativePath : ''; - if (rawPath && relative) { - var suffix = relative.split('/').filter(Boolean).join('/'); - if (suffix && rawPath.endsWith(suffix)) { - return rawPath.slice(0, rawPath.length - suffix.length).replace(/[\/\\]$/, ''); - } - } - if (rawPath) return rawPath; - return ''; - } - - function requestBrowserFolderChoice(statusNode) { - return new Promise(function(resolve) { - var input = document.createElement('input'); - input.type = 'file'; - input.multiple = true; - input.setAttribute('webkitdirectory', ''); - input.setAttribute('directory', ''); - input.style.position = 'fixed'; - input.style.left = '-9999px'; - input.addEventListener('change', function() { - var selectedPath = readPathFromDirectoryFiles(input.files || []); - if (input.parentElement) input.parentElement.removeChild(input); - if (!selectedPath && statusNode instanceof HTMLElement) { - statusNode.textContent = '当前浏览器没有暴露本机绝对路径,请在下方输入路径。'; - } - resolve(selectedPath); - }, { once: true }); - document.body.appendChild(input); - input.click(); - }); - } - - async function requestNativeFolderChoice(statusNode) { - var desktopPicker = window.__mnoteDesktop && typeof window.__mnoteDesktop.selectLocalFolder === 'function' - ? window.__mnoteDesktop.selectLocalFolder - : null; - if (desktopPicker) { - var selected = await desktopPicker(); - return typeof selected === 'string' ? selected : ''; - } - if (typeof window.showDirectoryPicker === 'function') { - var handle = await window.showDirectoryPicker({ mode: 'read' }); - var handlePath = handle && (handle.path || handle.mnotePath || handle.nativePath); - if (typeof handlePath === 'string' && handlePath.trim()) return handlePath; - if (statusNode instanceof HTMLElement) { - statusNode.textContent = '已选择“' + (handle && handle.name ? handle.name : '文件夹') + '”,但浏览器没有暴露本机绝对路径,请在下方确认路径。'; - } - return ''; - } - return requestBrowserFolderChoice(statusNode); - } - - function openLocalFolderDialog(initialMessage) { - closeLocalFolderDialog(); - var recent = readRecentLocalRoots(); - var dialog = document.createElement('div'); - dialog.className = 'mnote-local-folder-dialog'; - dialog.setAttribute('data-testid', 'mnote-local-folder-dialog'); - dialog.setAttribute('role', 'dialog'); - dialog.setAttribute('aria-modal', 'true'); - dialog.style.position = 'fixed'; - dialog.style.inset = '0'; - dialog.style.zIndex = '2147483646'; - dialog.style.background = 'rgba(15, 23, 42, 0.28)'; - dialog.style.display = 'flex'; - dialog.style.alignItems = 'center'; - dialog.style.justifyContent = 'center'; - var card = document.createElement('div'); - card.className = 'mnote-local-folder-dialog__card'; - card.style.width = 'min(640px, calc(100vw - 32px))'; - card.style.background = '#fff'; - card.style.border = '1px solid rgba(27, 28, 28, 0.12)'; - card.style.borderRadius = '8px'; - card.style.padding = '16px'; - card.style.boxShadow = '0 18px 48px rgba(15, 23, 42, 0.22)'; - card.style.display = 'grid'; - card.style.gap = '12px'; - var title = document.createElement('h2'); - title.textContent = '打开本地文件夹'; - title.style.margin = '0'; - title.style.fontSize = '18px'; - title.style.lineHeight = '1.4'; - var status = document.createElement('p'); - status.className = 'mnote-local-folder-dialog__status'; - status.setAttribute('data-testid', 'mnote-local-folder-status'); - status.textContent = initialMessage || '选择一个本机文件夹,或输入绝对路径。'; - status.style.margin = '0'; - status.style.color = '#4b5563'; - status.style.fontSize = '13px'; - var input = document.createElement('input'); - input.type = 'text'; - input.autocomplete = 'off'; - input.spellcheck = false; - input.placeholder = '/mnt/Data1T/mnote/design/04-tree-domain/done'; - input.setAttribute('data-testid', 'mnote-local-folder-path-input'); - input.value = recent.length ? fileRootUriToPathInput(recent[0]) : ''; - input.style.width = '100%'; - input.style.boxSizing = 'border-box'; - input.style.border = '1px solid rgba(27, 28, 28, 0.18)'; - input.style.borderRadius = '6px'; - input.style.padding = '10px 12px'; - input.style.fontSize = '14px'; - var actions = document.createElement('div'); - actions.className = 'mnote-local-folder-dialog__actions'; - actions.style.display = 'flex'; - actions.style.gap = '8px'; - actions.style.justifyContent = 'flex-end'; - var choose = document.createElement('button'); - choose.type = 'button'; - choose.textContent = '选择文件夹'; - choose.setAttribute('data-testid', 'mnote-local-folder-native-picker'); - var cancel = document.createElement('button'); - cancel.type = 'button'; - cancel.textContent = '取消'; - var confirm = document.createElement('button'); - confirm.type = 'button'; - confirm.textContent = '打开'; - confirm.setAttribute('data-testid', 'mnote-local-folder-open-confirm'); - [choose, cancel, confirm].forEach(function(button) { - button.style.border = '1px solid rgba(27, 28, 28, 0.14)'; - button.style.borderRadius = '6px'; - button.style.padding = '8px 12px'; - button.style.background = '#fff'; - button.style.cursor = 'pointer'; - button.style.fontSize = '14px'; - }); - function submit() { - var rootUri = pathToFileRootUri(input.value); - if (!rootUri) { - status.textContent = '请输入以 / 开头的绝对路径,或 file:// URI。'; - input.focus(); - return; - } - closeLocalFolderDialog(); - openLocalFolderRoot(rootUri); - } - choose.addEventListener('click', function(event) { - event.preventDefault(); - requestNativeFolderChoice(status).then(function(selectedPath) { - if (selectedPath) { - input.value = selectedPath; - submit(); - } - }).catch(function(error) { - status.textContent = error && error.name === 'AbortError' - ? '已取消选择。' - : '无法打开系统文件夹选择器,请输入路径。'; - }); - }); - cancel.addEventListener('click', function(event) { - event.preventDefault(); - closeLocalFolderDialog(); - }); - confirm.addEventListener('click', function(event) { - event.preventDefault(); - submit(); - }); - input.addEventListener('keydown', function(event) { - if (event.key === 'Enter') { - event.preventDefault(); - submit(); - } - }); - actions.appendChild(choose); - actions.appendChild(cancel); - actions.appendChild(confirm); - card.appendChild(title); - card.appendChild(status); - card.appendChild(input); - var authorizedSection = document.createElement('div'); - authorizedSection.className = 'mnote-local-folder-dialog__authorized'; - authorizedSection.setAttribute('data-testid', 'mnote-local-folder-authorized-roots'); - authorizedSection.hidden = true; - card.appendChild(authorizedSection); - fetch('/api/user/access-policy', { - method: 'GET', - headers: { 'accept': 'application/json' }, - credentials: 'include' - }).then(function(response) { - return response.ok ? response.json() : null; - }).then(function(payload) { - var grants = payload && Array.isArray(payload.grants) ? payload.grants : []; - var activeGrants = grants.filter(function(grant) { - return grant && grant.active !== false && grant.status !== 'revoked'; - }); - if (!activeGrants.length) return; - authorizedSection.hidden = false; - var authorizedTitle = document.createElement('div'); - authorizedTitle.style.fontSize = '12px'; - authorizedTitle.style.color = '#6b7280'; - authorizedTitle.textContent = '已授权文件夹'; - var authorizedList = document.createElement('div'); - authorizedList.className = 'mnote-local-folder-dialog__recent'; - activeGrants.slice(0, 8).forEach(function(grant) { - if (isDefaultWorkspaceAutoGrant(grant)) return; - var rootUri = normalizeGrantedLocalFolderRootUri(grant); - if (!rootUri) return; - var button = document.createElement('button'); - button.type = 'button'; - button.setAttribute('data-testid', 'mnote-local-folder-authorized-root'); - button.textContent = fileRootUriToPathInput(rootUri); - button.addEventListener('click', function(event) { - event.preventDefault(); - closeLocalFolderDialog(); - openLocalFolderRoot(rootUri); - }); - authorizedList.appendChild(button); - }); - if (!authorizedList.childElementCount) return; - authorizedSection.appendChild(authorizedTitle); - authorizedSection.appendChild(authorizedList); - }).catch(function() {}); - if (recent.length) { - var recentTitle = document.createElement('div'); - recentTitle.style.fontSize = '12px'; - recentTitle.style.color = '#6b7280'; - recentTitle.textContent = '最近使用'; - card.appendChild(recentTitle); - var recentList = document.createElement('div'); - recentList.className = 'mnote-local-folder-dialog__recent'; - recent.slice(0, 5).forEach(function(rootUri) { - var button = document.createElement('button'); - button.type = 'button'; - button.textContent = recentLocalRootLabel(rootUri); - button.addEventListener('click', function(event) { - event.preventDefault(); - closeLocalFolderDialog(); - openLocalFolderRoot(rootUri); - }); - recentList.appendChild(button); - }); - card.appendChild(recentList); - } - card.appendChild(actions); - dialog.appendChild(card); - dialog.addEventListener('click', function(event) { - if (event.target === dialog) closeLocalFolderDialog(); - }); - document.body.appendChild(dialog); - input.focus(); - input.select(); - } - - function requestOpenLocalFolder() { - openLocalFolderDialog(''); - } - - async function createDefaultLocalWorkspace(trigger) { - setCommandPending(trigger, true); - try { - var response = await fetch('/api/local-folder/workspaces/default', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: '{}' - }); - var payload = await response.json().catch(function() { return null; }); - if (!response.ok) { - throw new Error((payload && payload.message) || ('local_workspace_create_failed_' + response.status)); - } - var workspace = payload && payload.workspace || {}; - var rootUri = String(workspace.rootUri || '').trim(); - if (!rootUri) throw new Error('local_workspace_create_missing_root_uri'); - openLocalFolderRoot(rootUri); - } catch (error) { - var status = document.querySelector('[data-testid="mnote-local-folder-status"]'); - if (status instanceof HTMLElement) { - status.textContent = error && error.message ? error.message : String(error); - } else { - openLocalFolderDialog(error && error.message ? error.message : String(error)); - } - } finally { - setCommandPending(trigger, false); - } - } - - function autoOpenRecentLocalRootOnHome() { - if (window.location.pathname !== '/') return false; - var params = new URLSearchParams(window.location.search); - if ((params.get('sourceKind') || '').trim()) return false; - if ((params.get('rootUri') || '').trim()) return false; - if ((params.get('workspaceId') || '').trim()) return false; - if ((params.get('pageId') || '').trim()) return false; - if ((params.get('mnoteHome') || '').trim() === '1') return false; - var recent = readRecentLocalRoots(); - if (!recent.length) return false; - openLocalFolderRoot(recent[0]); - return true; - } - - function closeWorkspaceSourceMenu() { - var existing = document.querySelector('[data-testid="mnote-workspace-source-menu"]'); - if (existing && existing.parentElement) existing.parentElement.removeChild(existing); - document.querySelectorAll('[data-testid="mnote-workspace-source-trigger"]').forEach(function(trigger) { - if (trigger instanceof HTMLElement) trigger.setAttribute('aria-expanded', 'false'); - }); - } - - function workspaceSourceLabel(rootUri) { - var label = String(rootUri || '').replace(/^file:\/\//, ''); - try { - label = decodeURIComponent(label); - } catch (_) {} - return label || '本地文件夹'; - } - - function openWorkspaceSourceMenu(trigger) { - closeWorkspaceSourceMenu(); - if (!(trigger instanceof HTMLElement)) return; - trigger.setAttribute('aria-expanded', 'true'); - var menu = document.createElement('div'); - menu.className = 'mnote-workspace-source-menu'; - menu.setAttribute('data-testid', 'mnote-workspace-source-menu'); - menu.setAttribute('role', 'menu'); - var cloudButton = document.createElement('button'); - cloudButton.type = 'button'; - cloudButton.className = 'mnote-workspace-source-menu__item'; - cloudButton.setAttribute('data-testid', 'mnote-switch-cloud-workspace'); - cloudButton.setAttribute('role', 'menuitem'); - cloudButton.textContent = '我的空间'; - cloudButton.addEventListener('click', function(event) { - event.preventDefault(); - closeWorkspaceSourceMenu(); - switchToCloudWorkspace(); - }); - menu.appendChild(cloudButton); - var recent = readRecentLocalRoots(); - if (recent.length) { - var label = document.createElement('div'); - label.className = 'mnote-workspace-source-menu__label'; - label.textContent = '最近本地文件夹'; - menu.appendChild(label); - recent.slice(0, 8).forEach(function(rootUri) { - var button = document.createElement('button'); - button.type = 'button'; - button.className = 'mnote-workspace-source-menu__item'; - button.setAttribute('data-testid', 'mnote-recent-local-root'); - button.setAttribute('data-root-uri', rootUri); - button.setAttribute('role', 'menuitem'); - button.textContent = workspaceSourceLabel(rootUri); - button.addEventListener('click', function(event) { - event.preventDefault(); - closeWorkspaceSourceMenu(); - openLocalFolderRoot(rootUri); - }); - menu.appendChild(button); - }); - } - var openOther = document.createElement('button'); - openOther.type = 'button'; - openOther.className = 'mnote-workspace-source-menu__item mnote-workspace-source-menu__item--primary'; - openOther.setAttribute('data-testid', 'mnote-open-other-local-folder'); - openOther.setAttribute('role', 'menuitem'); - openOther.textContent = '打开其他本地文件夹'; - openOther.addEventListener('click', function(event) { - event.preventDefault(); - closeWorkspaceSourceMenu(); - requestOpenLocalFolder(); - }); - menu.appendChild(openOther); - trigger.closest('[data-testid="wolai-workspace-identity"]')?.appendChild(menu); - } - - function closeAccountMenu() { - var existing = document.querySelector('[data-testid="mnote-account-menu"]'); - if (existing && existing.parentElement) existing.parentElement.removeChild(existing); - document.querySelectorAll('[data-testid="mnote-account-menu-trigger"]').forEach(function(trigger) { - if (trigger instanceof HTMLElement) trigger.setAttribute('aria-expanded', 'false'); - }); - } - - function closeProfileDialog() { - var existing = document.querySelector('[data-testid="mnote-profile-dialog"]'); - if (existing && existing.parentElement) existing.parentElement.removeChild(existing); - } - - function closeAdminAccessPolicyDialog() { - var existing = document.querySelector('[data-testid="mnote-admin-access-policy-modal"]'); - if (existing && existing.parentElement) existing.parentElement.removeChild(existing); - document.documentElement.removeAttribute('data-mnote-admin-access-policy-modal-open'); - } - - function accountMenuFallbackSession() { - var body = document.body instanceof HTMLElement ? document.body : null; - var actorId = body ? String(body.getAttribute('data-mnote-actor-id') || '').trim() : ''; - return { - ok: true, - name: actorId || '当前用户', - email: '', - userId: actorId || 'anonymous', - actorType: actorId && actorId !== 'anonymous' ? 'user' : 'anonymous', - authMode: 'browser' - }; - } - - function renderAccountInfo(menu, session) { - var nameNode = menu.querySelector('[data-account-info="name"]'); - var emailNode = menu.querySelector('[data-account-info="email"]'); - var idNode = menu.querySelector('[data-account-info="user-id"]'); - var typeNode = menu.querySelector('[data-account-info="actor-type"]'); - if (nameNode) nameNode.textContent = session.name || session.userId || '当前用户'; - if (emailNode) emailNode.textContent = session.email || '未提供邮箱'; - if (idNode) { - idNode.textContent = session.userId || 'anonymous'; - idNode.setAttribute('title', session.userId || 'anonymous'); - } - if (typeNode) typeNode.textContent = session.actorType || session.authMode || 'unknown'; - var copyButton = menu.querySelector('[data-account-copy-user-id]'); - if (copyButton instanceof HTMLElement) { - copyButton.dataset.copyValue = session.userId || 'anonymous'; - copyButton.setAttribute('title', '复制用户 ID'); - } - } - - function sessionIsAdmin(session) { - return String(session && session.actorType || '').trim() === 'admin'; - } - - async function loadAccountInfo(menu) { - try { - var response = await fetch('/api/auth/session', { - method: 'GET', - headers: { 'accept': 'application/json' }, - credentials: 'include' - }); - var payload = await response.json().catch(function() { return null; }); - if (!response.ok || !payload) throw new Error('session_failed_' + response.status); - renderAccountInfo(menu, payload); - } catch (_) { - renderAccountInfo(menu, accountMenuFallbackSession()); - } - } - - async function fetchAccountSession() { - try { - var response = await fetch('/api/auth/session', { - method: 'GET', - headers: { 'accept': 'application/json' }, - credentials: 'include' - }); - var payload = await response.json().catch(function() { return null; }); - if (!response.ok || !payload) throw new Error('session_failed_' + response.status); - return payload; - } catch (_) { - return accountMenuFallbackSession(); - } - } - - function openProfileDialog(session) { - closeProfileDialog(); - closeAccountMenu(); - var dialog = document.createElement('div'); - dialog.className = 'mnote-profile-dialog'; - dialog.setAttribute('data-testid', 'mnote-profile-dialog'); - dialog.setAttribute('role', 'dialog'); - dialog.setAttribute('aria-modal', 'true'); - dialog.setAttribute('aria-labelledby', 'mnote-profile-dialog-title'); - var userId = escapeHtml(session.userId || 'anonymous'); - dialog.innerHTML = - '
' + - '
' + - '
' + - '
' + - '
个人信息
' + - '

个人信息

' + - '
' + - '' + - '
' + - '
' + - '
' + escapeHtml(String(session.name || session.email || session.userId || '用').slice(0, 1).toUpperCase()) + '
' + - '
' + escapeHtml(session.name || session.userId || '当前用户') + '' + escapeHtml(session.email || '未提供邮箱') + '
' + - '
' + - '
' + - '
用户名
' + escapeHtml(session.name || '未设置') + '
' + - '
邮箱
' + escapeHtml(session.email || '未提供邮箱') + '
' + - '
用户 ID
' + userId + '
' + - '
身份
' + escapeHtml(session.actorType || session.authMode || 'unknown') + '
' + - '
' + - '
'; - dialog.querySelectorAll('[data-profile-dialog-close]').forEach(function(button) { - button.addEventListener('click', function(event) { - event.preventDefault(); - closeProfileDialog(); - }); - }); - var copyButton = dialog.querySelector('[data-profile-copy-user-id]'); - if (copyButton) { - copyButton.addEventListener('click', function(event) { - event.preventDefault(); - void copyTreeContextValue(copyButton.getAttribute('data-copy-value') || '', 'profile-user-id'); - }); - } - document.body.appendChild(dialog); - var closeButton = dialog.querySelector('.mnote-profile-dialog__close'); - if (closeButton instanceof HTMLElement) closeButton.focus(); - } - - function openAdminAccessPolicyDialog(trigger) { - closeAdminAccessPolicyDialog(); - closeAccountMenu(); - if (!(trigger instanceof HTMLElement)) return; - var role = trigger.getAttribute('data-access-policy-role') === 'admin' ? 'admin' : 'user'; - var template = document.querySelector('[data-testid="mnote-admin-access-policy-template-' + role + '"]'); - var dialog = document.createElement('div'); - dialog.className = 'mnote-admin-policy-modal'; - dialog.setAttribute('data-testid', 'mnote-admin-access-policy-modal'); - dialog.setAttribute('role', 'dialog'); - dialog.setAttribute('aria-modal', 'true'); - dialog.setAttribute('aria-labelledby', 'mnote-admin-policy-dialog-title'); - dialog.innerHTML = - '
' + - '
' + - '' + - '
' + - '
'; - var content = dialog.querySelector('[data-testid="mnote-admin-access-policy-modal-content"]'); - if (content) { - content.innerHTML = template ? template.innerHTML : '
授权管理面板加载失败
'; - } - if (typeof window.MNOTEInitAccessPolicyPanel === 'function') { - window.MNOTEInitAccessPolicyPanel(dialog); - } - dialog.querySelectorAll('[data-admin-access-policy-close]').forEach(function(button) { - button.addEventListener('click', function(event) { - event.preventDefault(); - closeAdminAccessPolicyDialog(); - }); - }); - document.body.appendChild(dialog); - document.documentElement.setAttribute('data-mnote-admin-access-policy-modal-open', 'true'); - var closeButton = dialog.querySelector('.mnote-admin-policy-modal__close'); - if (closeButton instanceof HTMLElement) closeButton.focus(); - } - - async function signOutAccount(trigger) { - setCommandPending(trigger, true); - try { - var response = await fetch('/api/auth', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ action: 'auth:signOut', args: {} }) - }); - var payload = await response.json().catch(function() { return {}; }); - payload = payload || {}; - if (!response.ok || payload.error) { - throw new Error(payload.error || '退出登录失败'); - } - window.location.assign('/auth'); - } catch (error) { - var menu = document.querySelector('[data-testid="mnote-account-menu"]'); - var errorNode = menu ? menu.querySelector('[data-account-error]') : null; - if (errorNode instanceof HTMLElement) { - errorNode.textContent = error && error.message ? error.message : '退出登录失败'; - errorNode.hidden = false; - } - setCommandPending(trigger, false); - } - } - - function openAccountMenu(trigger) { - closeAccountMenu(); - if (!(trigger instanceof HTMLElement)) return; - trigger.setAttribute('aria-expanded', 'true'); - var menu = document.createElement('div'); - menu.className = 'mnote-workspace-source-menu mnote-account-menu'; - menu.setAttribute('data-testid', 'mnote-account-menu'); - menu.setAttribute('role', 'menu'); - menu.innerHTML = - '' + - '' + - '' + - ''; - var sessionPromise = fetchAccountSession(); - var profileButton = menu.querySelector('[data-testid="mnote-account-profile"]'); - if (profileButton) { - profileButton.addEventListener('click', function(event) { - event.preventDefault(); - sessionPromise.then(openProfileDialog); - }); - } - var accessPolicyLink = menu.querySelector('[data-testid="mnote-account-access-policy"]'); - sessionPromise.then(function(session) { - if (!(accessPolicyLink instanceof HTMLElement)) return; - accessPolicyLink.hidden = false; - accessPolicyLink.setAttribute('data-access-policy-role', sessionIsAdmin(session) ? 'admin' : 'user'); - }); - if (accessPolicyLink) { - accessPolicyLink.addEventListener('click', function(event) { - event.preventDefault(); - openAdminAccessPolicyDialog(accessPolicyLink); - }); - } - var signOutButton = menu.querySelector('[data-testid="mnote-account-sign-out"]'); - if (signOutButton) { - signOutButton.addEventListener('click', function(event) { - event.preventDefault(); - void signOutAccount(signOutButton); - }); - } - var host = trigger.closest('[data-testid="wolai-sidebar-quick-actions"]') || trigger.parentElement; - if (host) host.appendChild(menu); - } - - autoOpenRecentLocalRootOnHome(); + const sidebarWorkspace = createSidebarWorkspaceRuntime({ + cssEscape, + currentDocumentId, + escapeHtml, + parseJsonScript, + setCommandPending: (...args) => setCommandPending(...args), + sidebarShellRuntimeFunction, + }); + const normalizeSidebarTreeMode = (...args) => sidebarWorkspace.normalizeSidebarTreeMode(...args); + const readStoredSidebarTreeMode = (...args) => sidebarWorkspace.readStoredSidebarTreeMode(...args); + const persistSidebarTreeMode = (...args) => sidebarWorkspace.persistSidebarTreeMode(...args); + const activeSidebarTreeMode = (...args) => sidebarWorkspace.activeSidebarTreeMode(...args); + const resolveWorkspaceId = (...args) => sidebarWorkspace.resolveWorkspaceId(...args); + const currentWorkspaceId = (...args) => sidebarWorkspace.currentWorkspaceId(...args); + const currentSourceKind = (...args) => sidebarWorkspace.currentSourceKind(...args); + const currentRootUri = (...args) => sidebarWorkspace.currentRootUri(...args); + const copyWorkspaceSourceParams = (...args) => sidebarWorkspace.copyWorkspaceSourceParams(...args); + const currentWorkspaceSourcePayload = (...args) => sidebarWorkspace.currentWorkspaceSourcePayload(...args); + const openTrashModal = (...args) => sidebarWorkspace.openTrashModal(...args); + const closeTrashModal = (...args) => sidebarWorkspace.closeTrashModal(...args); + const closeLocalFolderDialog = (...args) => sidebarWorkspace.closeLocalFolderDialog(...args); + const requestOpenLocalFolder = (...args) => sidebarWorkspace.requestOpenLocalFolder(...args); + const createDefaultLocalWorkspace = (...args) => sidebarWorkspace.createDefaultLocalWorkspace(...args); + const closeWorkspaceSourceMenu = (...args) => sidebarWorkspace.closeWorkspaceSourceMenu(...args); + const openWorkspaceSourceMenu = (...args) => sidebarWorkspace.openWorkspaceSourceMenu(...args); + const closeAccountMenu = (...args) => sidebarWorkspace.closeAccountMenu(...args); + const openAccountMenu = (...args) => sidebarWorkspace.openAccountMenu(...args); + sidebarWorkspace.autoOpenRecentLocalRootOnHome(); function setCommandPending(trigger, pending) { if (!(trigger instanceof HTMLElement)) return; diff --git a/rust/crates/mnote-web/browser/sidebar-workspace-runtime.js b/rust/crates/mnote-web/browser/sidebar-workspace-runtime.js new file mode 100644 index 00000000..074605d2 --- /dev/null +++ b/rust/crates/mnote-web/browser/sidebar-workspace-runtime.js @@ -0,0 +1,1009 @@ +export const createSidebarWorkspaceRuntime = (dependencies = {}) => { + const { + cssEscape, + currentDocumentId, + escapeHtml, + parseJsonScript, + setCommandPending, + sidebarShellRuntimeFunction, + } = dependencies; + const MNOTE_SIDEBAR_TREE_MODE_KEY = 'mnote.sidebar.tree.mode'; + const MNOTE_RECENT_LOCAL_ROOTS_KEY_PREFIX = 'mnote.localFolder.recentRoots:'; + const MNOTE_LAST_CLOUD_WORKSPACE_KEY = 'mnote.workspace.lastCloudWorkspaceId'; + + function normalizeSidebarTreeMode(value) { + var mode = String(value || '').trim(); + return mode === 'filetree' ? 'filetree' : 'page'; + } + + function readStoredSidebarTreeMode() { + var runtimeFn = sidebarShellRuntimeFunction('readStoredSidebarTreeMode'); + if (runtimeFn) return runtimeFn(); + var params = new URLSearchParams(window.location.search); + var fromUrl = normalizeSidebarTreeMode(params.get('treeView') || params.get('sidebarTree')); + if (params.has('treeView') || params.has('sidebarTree')) return fromUrl; + try { + var stored = window.sessionStorage ? window.sessionStorage.getItem(MNOTE_SIDEBAR_TREE_MODE_KEY) : ''; + return normalizeSidebarTreeMode(stored); + } catch (_) { + return 'page'; + } + } + + function persistSidebarTreeMode(mode) { + var runtimeFn = sidebarShellRuntimeFunction('persistSidebarTreeMode'); + if (runtimeFn) return runtimeFn(mode); + var normalized = normalizeSidebarTreeMode(mode); + document.documentElement.setAttribute('data-mnote-sidebar-tree-mode', normalized); + try { + if (window.sessionStorage) window.sessionStorage.setItem(MNOTE_SIDEBAR_TREE_MODE_KEY, normalized); + } catch (_) {} + return normalized; + } + + function activeSidebarTreeMode() { + var runtimeFn = sidebarShellRuntimeFunction('activeSidebarTreeMode'); + if (runtimeFn) return runtimeFn(); + var active = document.querySelector('[data-mnote-sidebar-tree-tab][aria-selected="true"]'); + if (active instanceof HTMLElement) { + return normalizeSidebarTreeMode(active.getAttribute('data-mnote-sidebar-tree-tab')); + } + return readStoredSidebarTreeMode(); + } + + function resolveWorkspaceId(trigger) { + var direct = (trigger.getAttribute('data-workspace-id') || '').trim(); + if (direct) return direct; + var root = trigger.closest('[data-workspace-id]'); + if (root) { + var value = (root.getAttribute('data-workspace-id') || '').trim(); + if (value) return value; + } + var documentId = currentDocumentId(); + if (documentId) { + var shell = document.querySelector('.document-shell[data-document-id="' + cssEscape(documentId) + '"][data-workspace-id]'); + if (shell instanceof HTMLElement) { + var shellWorkspaceId = (shell.getAttribute('data-workspace-id') || '').trim(); + if (shellWorkspaceId) return shellWorkspaceId; + } + } + var activePane = document.querySelector('[data-pane-visible="true"][data-pane-workspace-id]'); + if (activePane instanceof HTMLElement) { + var paneWorkspaceId = (activePane.getAttribute('data-pane-workspace-id') || '').trim(); + if (paneWorkspaceId) return paneWorkspaceId; + } + var anyDocumentShell = document.querySelector('.document-shell[data-workspace-id]'); + if (anyDocumentShell instanceof HTMLElement) { + var documentWorkspaceId = (anyDocumentShell.getAttribute('data-workspace-id') || '').trim(); + if (documentWorkspaceId) return documentWorkspaceId; + } + var fromDom = document.querySelector('[data-workspace-id]'); + if (fromDom instanceof HTMLElement) { + var domWorkspaceId = (fromDom.getAttribute('data-workspace-id') || '').trim(); + if (domWorkspaceId) return domWorkspaceId; + } + return (new URLSearchParams(window.location.search).get('workspaceId') || 'default').trim() || 'default'; + } + + function currentWorkspaceId() { + return resolveWorkspaceId(document.body); + } + + function currentSourceKind() { + var params = new URLSearchParams(window.location.search); + var fromUrl = (params.get('sourceKind') || '').trim(); + if (fromUrl) return fromUrl; + var fromBody = document.body instanceof HTMLElement ? (document.body.getAttribute('data-mnote-source-kind') || '').trim() : ''; + if (fromBody) return fromBody; + if ( + window.location.pathname === '/' && + !(params.get('workspaceId') || '').trim() && + !(params.get('rootUri') || '').trim() && + !(params.get('pageId') || '').trim() + ) { + return 'local_folder'; + } + return 'convex_workspace'; + } + + function currentRootUri() { + var params = new URLSearchParams(window.location.search); + var fromUrl = (params.get('rootUri') || '').trim(); + if (fromUrl) return fromUrl; + return document.body instanceof HTMLElement ? (document.body.getAttribute('data-mnote-root-uri') || '').trim() : ''; + } + + function rememberCloudWorkspaceId(workspaceId) { + var normalized = String(workspaceId || '').trim(); + if (!normalized || normalized === 'local-folder' || normalized === 'default' || normalized.indexOf('local-ws:') === 0) return; + try { + if (window.localStorage) window.localStorage.setItem(MNOTE_LAST_CLOUD_WORKSPACE_KEY, normalized); + } catch (_) {} + } + + function readCurrentCloudWorkspaceIdFromPage() { + if (currentSourceKind() === 'local_folder') return ''; + var params = new URLSearchParams(window.location.search); + var fromUrl = (params.get('workspaceId') || '').trim(); + if (fromUrl && fromUrl !== 'default' && fromUrl.indexOf('local-ws:') !== 0) return fromUrl; + var fromDom = document.querySelector('[data-workspace-id]'); + if (fromDom instanceof HTMLElement) { + var value = (fromDom.getAttribute('data-workspace-id') || '').trim(); + if (value && value !== 'local-folder' && value !== 'default' && value.indexOf('local-ws:') !== 0) return value; + } + return ''; + } + + function rememberCurrentCloudWorkspaceId() { + rememberCloudWorkspaceId(readCurrentCloudWorkspaceIdFromPage()); + } + + function readLastCloudWorkspaceId() { + try { + var stored = window.localStorage ? window.localStorage.getItem(MNOTE_LAST_CLOUD_WORKSPACE_KEY) : ''; + if (stored && stored.trim() && stored.trim() !== 'default' && stored.trim().indexOf('local-ws:') !== 0) return stored.trim(); + } catch (_) {} + var params = new URLSearchParams(window.location.search); + var fromUrl = (params.get('workspaceId') || '').trim(); + if (fromUrl && currentSourceKind() !== 'local_folder' && fromUrl.indexOf('local-ws:') !== 0) return fromUrl; + var fromDom = document.querySelector('[data-workspace-id]'); + if (fromDom instanceof HTMLElement) { + var value = (fromDom.getAttribute('data-workspace-id') || '').trim(); + if (value && value !== 'local-folder' && value.indexOf('local-ws:') !== 0) return value; + } + return ''; + } + + function copyWorkspaceSourceParams(targetUrl) { + var params = new URLSearchParams(window.location.search); + ['sourceKind', 'rootUri', 'secondaryDocumentId', 'secondarySourceKind', 'secondaryRootUri'].forEach(function(name) { + var value = (params.get(name) || '').trim(); + if (value) targetUrl.searchParams.set(name, value); + }); + var sourceKind = currentSourceKind(); + var rootUri = currentRootUri(); + if (sourceKind && !targetUrl.searchParams.get('sourceKind')) targetUrl.searchParams.set('sourceKind', sourceKind); + if (rootUri && !targetUrl.searchParams.get('rootUri')) targetUrl.searchParams.set('rootUri', rootUri); + } + + function currentWorkspaceSourcePayload() { + var params = new URLSearchParams(window.location.search); + var payload = {}; + ['sourceKind', 'rootUri'].forEach(function(name) { + var value = (params.get(name) || '').trim(); + if (value) payload[name] = value; + }); + var sourceKind = currentSourceKind(); + var rootUri = currentRootUri(); + if (sourceKind && !payload.sourceKind) payload.sourceKind = sourceKind; + if (rootUri && !payload.rootUri) payload.rootUri = rootUri; + return payload; + } + + function currentActorStorageId() { + var fromBody = document.body instanceof HTMLElement ? (document.body.getAttribute('data-mnote-actor-id') || '').trim() : ''; + if (fromBody && fromBody !== 'anonymous') return fromBody; + return ''; + } + + function recentLocalRootsStorageKey() { + var actorId = currentActorStorageId(); + return actorId ? MNOTE_RECENT_LOCAL_ROOTS_KEY_PREFIX + encodeURIComponent(actorId) : ''; + } + + function readRecentLocalRoots() { + try { + var storageKey = recentLocalRootsStorageKey(); + if (!storageKey) return []; + var raw = window.localStorage ? window.localStorage.getItem(storageKey) : ''; + var parsed = raw ? JSON.parse(raw) : []; + return Array.isArray(parsed) ? parsed.filter(function(value) { + return typeof value === 'string' && value.trim(); + }).slice(0, 10) : []; + } catch (_) { + return []; + } + } + + function rememberLocalRoot(rootUri) { + try { + if (!window.localStorage) return; + var storageKey = recentLocalRootsStorageKey(); + if (!storageKey) return; + var roots = readRecentLocalRoots().filter(function(value) { return value !== rootUri; }); + roots.unshift(rootUri); + window.localStorage.setItem(storageKey, JSON.stringify(roots.slice(0, 10))); + } catch (_) {} + } + + function pathToFileRootUri(value) { + var trimmed = String(value || '').trim(); + if (!trimmed) return ''; + if (/^file:\/\//i.test(trimmed)) return trimmed; + if (trimmed.charAt(0) !== '/') return ''; + return 'file://' + trimmed.split('/').map(function(part, index) { + return index === 0 ? '' : encodeURIComponent(part); + }).join('/'); + } + + function fileRootUriToPathInput(rootUri) { + var value = String(rootUri || '').replace(/^file:\/\//, ''); + try { + return decodeURIComponent(value); + } catch (_) { + return value; + } + } + + function recentLocalRootLabel(rootUri) { + var label = fileRootUriToPathInput(rootUri); + return label || '本地文件夹'; + } + + function normalizeGrantedLocalFolderRootUri(grant) { + var rootUri = String(grant && grant.rootUri || '').trim(); + if (rootUri && /^file:\/\//i.test(rootUri)) return rootUri; + var rootPath = String(grant && grant.rootPath || '').trim(); + if (rootPath) return pathToFileRootUri(rootPath); + if (rootUri) return pathToFileRootUri(rootUri); + return ''; + } + + function isDefaultWorkspaceAutoGrant(grant) { + var source = String(grant && grant.source || '').trim(); + var workspaceId = String(grant && grant.workspaceId || '').trim(); + var rootUri = String(grant && grant.rootUri || '').trim(); + var permission = String(grant && grant.permission || '').trim(); + var createdBy = String(grant && (grant.createdBy || grant.ownerUserId) || '').trim(); + var targetUser = String(grant && (grant.userId || grant.targetUserId) || '').trim(); + return source === 'auto' + && workspaceId + && permission === 'write' + && createdBy === targetUser + && /^local:\/\/users\/.+\/workspaces\/my-space$/.test(rootUri); + } + + function openLocalFolderRoot(rootUri) { + if (currentSourceKind() !== 'local_folder') { + rememberCurrentCloudWorkspaceId(); + } + rememberLocalRoot(rootUri); + var targetUrl = new URL('/', window.location.origin); + targetUrl.searchParams.set('treeView', 'filetree'); + targetUrl.searchParams.set('sourceKind', 'local_folder'); + targetUrl.searchParams.set('rootUri', rootUri); + window.location.href = targetUrl.toString(); + } + + function switchToCloudWorkspace() { + var targetUrl = new URL('/', window.location.origin); + targetUrl.searchParams.set('treeView', activeSidebarTreeMode() || 'filetree'); + targetUrl.searchParams.set('mnoteHome', '1'); + window.location.href = targetUrl.toString(); + } + + function closeTrashModal() { + var modal = document.querySelector('[data-testid="mnote-trash-modal"]'); + if (!modal) return; + var workbench = modal.querySelector('[data-testid="mnote-trash-workbench"]'); + if (workbench && workbench.__mnoteTrashEventSource && typeof workbench.__mnoteTrashEventSource.close === 'function') { + workbench.__mnoteTrashEventSource.close(); + } + var fileScrollTop = Number(modal.getAttribute('data-file-scroll-top') || '0'); + var fileRoot = document.getElementById('sidebar-file-tree-root'); + if (fileRoot instanceof HTMLElement && Number.isFinite(fileScrollTop)) fileRoot.scrollTop = fileScrollTop; + if (modal.parentElement) modal.parentElement.removeChild(modal); + document.documentElement.removeAttribute('data-mnote-trash-modal-open'); + } + + function executeTrashWorkbenchScripts(parsed) { + parsed.querySelectorAll('script').forEach(function(script) { + var nextScript = document.createElement('script'); + Array.from(script.attributes || []).forEach(function(attr) { + nextScript.setAttribute(attr.name, attr.value); + }); + nextScript.textContent = script.textContent || ''; + document.body.appendChild(nextScript); + if (nextScript.parentElement) nextScript.parentElement.removeChild(nextScript); + }); + } + + function renderLocalFolderTrashPlaceholder(content, rootUri) { + content.innerHTML = [ + '
', + '

本地文件夹垃圾箱

', + '

本地删除项保存在当前目录的 .mnote/trash.mnote/trash-index.json 中。

', + '
当前弹窗已保持在本地文件夹上下文:' + escapeHtml(rootUri || '未选择本地目录') + '
', + '
' + ].join(''); + } + + function openTrashModal(trigger) { + var existing = document.querySelector('[data-testid="mnote-trash-modal"]'); + if (existing) return; + var fileRoot = document.getElementById('sidebar-file-tree-root'); + var activeRow = document.querySelector('#sidebar-file-tree-root .tree-row[data-selected="true"], #sidebar-tree-root .tree-row[data-active="true"]'); + var overlay = document.createElement('div'); + overlay.className = 'mnote-trash-modal'; + overlay.setAttribute('data-testid', 'mnote-trash-modal'); + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + overlay.setAttribute('aria-label', '垃圾箱'); + overlay.setAttribute('data-file-scroll-top', fileRoot instanceof HTMLElement ? String(fileRoot.scrollTop) : '0'); + if (activeRow instanceof HTMLElement) { + overlay.setAttribute('data-active-row-id', activeRow.getAttribute('data-row-id') || activeRow.getAttribute('data-node-id') || ''); + } + overlay.innerHTML = '
' + + '
' + + '' + + '
正在加载垃圾箱...
' + + '
'; + document.body.appendChild(overlay); + document.documentElement.setAttribute('data-mnote-trash-modal-open', 'true'); + var content = overlay.querySelector('[data-testid="mnote-trash-modal-content"]'); + var sourceKind = currentSourceKind(); + var workspaceId = resolveWorkspaceId(trigger || document.body); + var url = new URL('/trash', window.location.origin); + if (sourceKind === 'local_folder') { + var rootUri = currentRootUri(); + if (!rootUri) { + renderLocalFolderTrashPlaceholder(content, ''); + return; + } + url.searchParams.set('sourceKind', 'local_folder'); + url.searchParams.set('rootUri', rootUri); + } else if (workspaceId) { + url.searchParams.set('workspaceId', workspaceId); + } + fetch(url.toString(), { headers: { 'x-mnote-trash-modal': '1' } }).then(function(response) { + return response.text().then(function(html) { + if (!response.ok) throw new Error('trash_modal_load_failed_' + response.status); + var parsed = new DOMParser().parseFromString(html, 'text/html'); + var workbench = parsed.querySelector('[data-testid="mnote-trash-workbench"]'); + if (!workbench) throw new Error('trash_modal_missing_workbench'); + content.innerHTML = ''; + content.appendChild(workbench); + executeTrashWorkbenchScripts(parsed); + }); + }).catch(function(error) { + content.innerHTML = '
' + escapeHtml(error && error.message ? error.message : String(error)) + '
'; + }); + } + + function closeLocalFolderDialog() { + var existing = document.querySelector('[data-testid="mnote-local-folder-dialog"]'); + if (existing && existing.parentElement) existing.parentElement.removeChild(existing); + } + + function readPathFromDirectoryFiles(files) { + var first = files && files.length ? files[0] : null; + if (!first) return ''; + var rawPath = typeof first.path === 'string' ? first.path : ''; + var relative = typeof first.webkitRelativePath === 'string' ? first.webkitRelativePath : ''; + if (rawPath && relative) { + var suffix = relative.split('/').filter(Boolean).join('/'); + if (suffix && rawPath.endsWith(suffix)) { + return rawPath.slice(0, rawPath.length - suffix.length).replace(/[\/\\]$/, ''); + } + } + if (rawPath) return rawPath; + return ''; + } + + function requestBrowserFolderChoice(statusNode) { + return new Promise(function(resolve) { + var input = document.createElement('input'); + input.type = 'file'; + input.multiple = true; + input.setAttribute('webkitdirectory', ''); + input.setAttribute('directory', ''); + input.style.position = 'fixed'; + input.style.left = '-9999px'; + input.addEventListener('change', function() { + var selectedPath = readPathFromDirectoryFiles(input.files || []); + if (input.parentElement) input.parentElement.removeChild(input); + if (!selectedPath && statusNode instanceof HTMLElement) { + statusNode.textContent = '当前浏览器没有暴露本机绝对路径,请在下方输入路径。'; + } + resolve(selectedPath); + }, { once: true }); + document.body.appendChild(input); + input.click(); + }); + } + + async function requestNativeFolderChoice(statusNode) { + var desktopPicker = window.__mnoteDesktop && typeof window.__mnoteDesktop.selectLocalFolder === 'function' + ? window.__mnoteDesktop.selectLocalFolder + : null; + if (desktopPicker) { + var selected = await desktopPicker(); + return typeof selected === 'string' ? selected : ''; + } + if (typeof window.showDirectoryPicker === 'function') { + var handle = await window.showDirectoryPicker({ mode: 'read' }); + var handlePath = handle && (handle.path || handle.mnotePath || handle.nativePath); + if (typeof handlePath === 'string' && handlePath.trim()) return handlePath; + if (statusNode instanceof HTMLElement) { + statusNode.textContent = '已选择“' + (handle && handle.name ? handle.name : '文件夹') + '”,但浏览器没有暴露本机绝对路径,请在下方确认路径。'; + } + return ''; + } + return requestBrowserFolderChoice(statusNode); + } + + function openLocalFolderDialog(initialMessage) { + closeLocalFolderDialog(); + var recent = readRecentLocalRoots(); + var dialog = document.createElement('div'); + dialog.className = 'mnote-local-folder-dialog'; + dialog.setAttribute('data-testid', 'mnote-local-folder-dialog'); + dialog.setAttribute('role', 'dialog'); + dialog.setAttribute('aria-modal', 'true'); + dialog.style.position = 'fixed'; + dialog.style.inset = '0'; + dialog.style.zIndex = '2147483646'; + dialog.style.background = 'rgba(15, 23, 42, 0.28)'; + dialog.style.display = 'flex'; + dialog.style.alignItems = 'center'; + dialog.style.justifyContent = 'center'; + var card = document.createElement('div'); + card.className = 'mnote-local-folder-dialog__card'; + card.style.width = 'min(640px, calc(100vw - 32px))'; + card.style.background = '#fff'; + card.style.border = '1px solid rgba(27, 28, 28, 0.12)'; + card.style.borderRadius = '8px'; + card.style.padding = '16px'; + card.style.boxShadow = '0 18px 48px rgba(15, 23, 42, 0.22)'; + card.style.display = 'grid'; + card.style.gap = '12px'; + var title = document.createElement('h2'); + title.textContent = '打开本地文件夹'; + title.style.margin = '0'; + title.style.fontSize = '18px'; + title.style.lineHeight = '1.4'; + var status = document.createElement('p'); + status.className = 'mnote-local-folder-dialog__status'; + status.setAttribute('data-testid', 'mnote-local-folder-status'); + status.textContent = initialMessage || '选择一个本机文件夹,或输入绝对路径。'; + status.style.margin = '0'; + status.style.color = '#4b5563'; + status.style.fontSize = '13px'; + var input = document.createElement('input'); + input.type = 'text'; + input.autocomplete = 'off'; + input.spellcheck = false; + input.placeholder = '/mnt/Data1T/mnote/design/04-tree-domain/done'; + input.setAttribute('data-testid', 'mnote-local-folder-path-input'); + input.value = recent.length ? fileRootUriToPathInput(recent[0]) : ''; + input.style.width = '100%'; + input.style.boxSizing = 'border-box'; + input.style.border = '1px solid rgba(27, 28, 28, 0.18)'; + input.style.borderRadius = '6px'; + input.style.padding = '10px 12px'; + input.style.fontSize = '14px'; + var actions = document.createElement('div'); + actions.className = 'mnote-local-folder-dialog__actions'; + actions.style.display = 'flex'; + actions.style.gap = '8px'; + actions.style.justifyContent = 'flex-end'; + var choose = document.createElement('button'); + choose.type = 'button'; + choose.textContent = '选择文件夹'; + choose.setAttribute('data-testid', 'mnote-local-folder-native-picker'); + var cancel = document.createElement('button'); + cancel.type = 'button'; + cancel.textContent = '取消'; + var confirm = document.createElement('button'); + confirm.type = 'button'; + confirm.textContent = '打开'; + confirm.setAttribute('data-testid', 'mnote-local-folder-open-confirm'); + [choose, cancel, confirm].forEach(function(button) { + button.style.border = '1px solid rgba(27, 28, 28, 0.14)'; + button.style.borderRadius = '6px'; + button.style.padding = '8px 12px'; + button.style.background = '#fff'; + button.style.cursor = 'pointer'; + button.style.fontSize = '14px'; + }); + function submit() { + var rootUri = pathToFileRootUri(input.value); + if (!rootUri) { + status.textContent = '请输入以 / 开头的绝对路径,或 file:// URI。'; + input.focus(); + return; + } + closeLocalFolderDialog(); + openLocalFolderRoot(rootUri); + } + choose.addEventListener('click', function(event) { + event.preventDefault(); + requestNativeFolderChoice(status).then(function(selectedPath) { + if (selectedPath) { + input.value = selectedPath; + submit(); + } + }).catch(function(error) { + status.textContent = error && error.name === 'AbortError' + ? '已取消选择。' + : '无法打开系统文件夹选择器,请输入路径。'; + }); + }); + cancel.addEventListener('click', function(event) { + event.preventDefault(); + closeLocalFolderDialog(); + }); + confirm.addEventListener('click', function(event) { + event.preventDefault(); + submit(); + }); + input.addEventListener('keydown', function(event) { + if (event.key === 'Enter') { + event.preventDefault(); + submit(); + } + }); + actions.appendChild(choose); + actions.appendChild(cancel); + actions.appendChild(confirm); + card.appendChild(title); + card.appendChild(status); + card.appendChild(input); + var authorizedSection = document.createElement('div'); + authorizedSection.className = 'mnote-local-folder-dialog__authorized'; + authorizedSection.setAttribute('data-testid', 'mnote-local-folder-authorized-roots'); + authorizedSection.hidden = true; + card.appendChild(authorizedSection); + fetch('/api/user/access-policy', { + method: 'GET', + headers: { 'accept': 'application/json' }, + credentials: 'include' + }).then(function(response) { + return response.ok ? response.json() : null; + }).then(function(payload) { + var grants = payload && Array.isArray(payload.grants) ? payload.grants : []; + var activeGrants = grants.filter(function(grant) { + return grant && grant.active !== false && grant.status !== 'revoked'; + }); + if (!activeGrants.length) return; + authorizedSection.hidden = false; + var authorizedTitle = document.createElement('div'); + authorizedTitle.style.fontSize = '12px'; + authorizedTitle.style.color = '#6b7280'; + authorizedTitle.textContent = '已授权文件夹'; + var authorizedList = document.createElement('div'); + authorizedList.className = 'mnote-local-folder-dialog__recent'; + activeGrants.slice(0, 8).forEach(function(grant) { + if (isDefaultWorkspaceAutoGrant(grant)) return; + var rootUri = normalizeGrantedLocalFolderRootUri(grant); + if (!rootUri) return; + var button = document.createElement('button'); + button.type = 'button'; + button.setAttribute('data-testid', 'mnote-local-folder-authorized-root'); + button.textContent = fileRootUriToPathInput(rootUri); + button.addEventListener('click', function(event) { + event.preventDefault(); + closeLocalFolderDialog(); + openLocalFolderRoot(rootUri); + }); + authorizedList.appendChild(button); + }); + if (!authorizedList.childElementCount) return; + authorizedSection.appendChild(authorizedTitle); + authorizedSection.appendChild(authorizedList); + }).catch(function() {}); + if (recent.length) { + var recentTitle = document.createElement('div'); + recentTitle.style.fontSize = '12px'; + recentTitle.style.color = '#6b7280'; + recentTitle.textContent = '最近使用'; + card.appendChild(recentTitle); + var recentList = document.createElement('div'); + recentList.className = 'mnote-local-folder-dialog__recent'; + recent.slice(0, 5).forEach(function(rootUri) { + var button = document.createElement('button'); + button.type = 'button'; + button.textContent = recentLocalRootLabel(rootUri); + button.addEventListener('click', function(event) { + event.preventDefault(); + closeLocalFolderDialog(); + openLocalFolderRoot(rootUri); + }); + recentList.appendChild(button); + }); + card.appendChild(recentList); + } + card.appendChild(actions); + dialog.appendChild(card); + dialog.addEventListener('click', function(event) { + if (event.target === dialog) closeLocalFolderDialog(); + }); + document.body.appendChild(dialog); + input.focus(); + input.select(); + } + + function requestOpenLocalFolder() { + openLocalFolderDialog(''); + } + + async function createDefaultLocalWorkspace(trigger) { + setCommandPending(trigger, true); + try { + var response = await fetch('/api/local-folder/workspaces/default', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}' + }); + var payload = await response.json().catch(function() { return null; }); + if (!response.ok) { + throw new Error((payload && payload.message) || ('local_workspace_create_failed_' + response.status)); + } + var workspace = payload && payload.workspace || {}; + var rootUri = String(workspace.rootUri || '').trim(); + if (!rootUri) throw new Error('local_workspace_create_missing_root_uri'); + openLocalFolderRoot(rootUri); + } catch (error) { + var status = document.querySelector('[data-testid="mnote-local-folder-status"]'); + if (status instanceof HTMLElement) { + status.textContent = error && error.message ? error.message : String(error); + } else { + openLocalFolderDialog(error && error.message ? error.message : String(error)); + } + } finally { + setCommandPending(trigger, false); + } + } + + function autoOpenRecentLocalRootOnHome() { + if (window.location.pathname !== '/') return false; + var params = new URLSearchParams(window.location.search); + if ((params.get('sourceKind') || '').trim()) return false; + if ((params.get('rootUri') || '').trim()) return false; + if ((params.get('workspaceId') || '').trim()) return false; + if ((params.get('pageId') || '').trim()) return false; + if ((params.get('mnoteHome') || '').trim() === '1') return false; + var recent = readRecentLocalRoots(); + if (!recent.length) return false; + openLocalFolderRoot(recent[0]); + return true; + } + + function closeWorkspaceSourceMenu() { + var existing = document.querySelector('[data-testid="mnote-workspace-source-menu"]'); + if (existing && existing.parentElement) existing.parentElement.removeChild(existing); + document.querySelectorAll('[data-testid="mnote-workspace-source-trigger"]').forEach(function(trigger) { + if (trigger instanceof HTMLElement) trigger.setAttribute('aria-expanded', 'false'); + }); + } + + function workspaceSourceLabel(rootUri) { + var label = String(rootUri || '').replace(/^file:\/\//, ''); + try { + label = decodeURIComponent(label); + } catch (_) {} + return label || '本地文件夹'; + } + + function openWorkspaceSourceMenu(trigger) { + closeWorkspaceSourceMenu(); + if (!(trigger instanceof HTMLElement)) return; + trigger.setAttribute('aria-expanded', 'true'); + var menu = document.createElement('div'); + menu.className = 'mnote-workspace-source-menu'; + menu.setAttribute('data-testid', 'mnote-workspace-source-menu'); + menu.setAttribute('role', 'menu'); + var cloudButton = document.createElement('button'); + cloudButton.type = 'button'; + cloudButton.className = 'mnote-workspace-source-menu__item'; + cloudButton.setAttribute('data-testid', 'mnote-switch-cloud-workspace'); + cloudButton.setAttribute('role', 'menuitem'); + cloudButton.textContent = '我的空间'; + cloudButton.addEventListener('click', function(event) { + event.preventDefault(); + closeWorkspaceSourceMenu(); + switchToCloudWorkspace(); + }); + menu.appendChild(cloudButton); + var recent = readRecentLocalRoots(); + if (recent.length) { + var label = document.createElement('div'); + label.className = 'mnote-workspace-source-menu__label'; + label.textContent = '最近本地文件夹'; + menu.appendChild(label); + recent.slice(0, 8).forEach(function(rootUri) { + var button = document.createElement('button'); + button.type = 'button'; + button.className = 'mnote-workspace-source-menu__item'; + button.setAttribute('data-testid', 'mnote-recent-local-root'); + button.setAttribute('data-root-uri', rootUri); + button.setAttribute('role', 'menuitem'); + button.textContent = workspaceSourceLabel(rootUri); + button.addEventListener('click', function(event) { + event.preventDefault(); + closeWorkspaceSourceMenu(); + openLocalFolderRoot(rootUri); + }); + menu.appendChild(button); + }); + } + var openOther = document.createElement('button'); + openOther.type = 'button'; + openOther.className = 'mnote-workspace-source-menu__item mnote-workspace-source-menu__item--primary'; + openOther.setAttribute('data-testid', 'mnote-open-other-local-folder'); + openOther.setAttribute('role', 'menuitem'); + openOther.textContent = '打开其他本地文件夹'; + openOther.addEventListener('click', function(event) { + event.preventDefault(); + closeWorkspaceSourceMenu(); + requestOpenLocalFolder(); + }); + menu.appendChild(openOther); + trigger.closest('[data-testid="wolai-workspace-identity"]')?.appendChild(menu); + } + + function closeAccountMenu() { + var existing = document.querySelector('[data-testid="mnote-account-menu"]'); + if (existing && existing.parentElement) existing.parentElement.removeChild(existing); + document.querySelectorAll('[data-testid="mnote-account-menu-trigger"]').forEach(function(trigger) { + if (trigger instanceof HTMLElement) trigger.setAttribute('aria-expanded', 'false'); + }); + } + + function closeProfileDialog() { + var existing = document.querySelector('[data-testid="mnote-profile-dialog"]'); + if (existing && existing.parentElement) existing.parentElement.removeChild(existing); + } + + function closeAdminAccessPolicyDialog() { + var existing = document.querySelector('[data-testid="mnote-admin-access-policy-modal"]'); + if (existing && existing.parentElement) existing.parentElement.removeChild(existing); + document.documentElement.removeAttribute('data-mnote-admin-access-policy-modal-open'); + } + + function accountMenuFallbackSession() { + var body = document.body instanceof HTMLElement ? document.body : null; + var actorId = body ? String(body.getAttribute('data-mnote-actor-id') || '').trim() : ''; + return { + ok: true, + name: actorId || '当前用户', + email: '', + userId: actorId || 'anonymous', + actorType: actorId && actorId !== 'anonymous' ? 'user' : 'anonymous', + authMode: 'browser' + }; + } + + function renderAccountInfo(menu, session) { + var nameNode = menu.querySelector('[data-account-info="name"]'); + var emailNode = menu.querySelector('[data-account-info="email"]'); + var idNode = menu.querySelector('[data-account-info="user-id"]'); + var typeNode = menu.querySelector('[data-account-info="actor-type"]'); + if (nameNode) nameNode.textContent = session.name || session.userId || '当前用户'; + if (emailNode) emailNode.textContent = session.email || '未提供邮箱'; + if (idNode) { + idNode.textContent = session.userId || 'anonymous'; + idNode.setAttribute('title', session.userId || 'anonymous'); + } + if (typeNode) typeNode.textContent = session.actorType || session.authMode || 'unknown'; + var copyButton = menu.querySelector('[data-account-copy-user-id]'); + if (copyButton instanceof HTMLElement) { + copyButton.dataset.copyValue = session.userId || 'anonymous'; + copyButton.setAttribute('title', '复制用户 ID'); + } + } + + function sessionIsAdmin(session) { + return String(session && session.actorType || '').trim() === 'admin'; + } + + async function loadAccountInfo(menu) { + try { + var response = await fetch('/api/auth/session', { + method: 'GET', + headers: { 'accept': 'application/json' }, + credentials: 'include' + }); + var payload = await response.json().catch(function() { return null; }); + if (!response.ok || !payload) throw new Error('session_failed_' + response.status); + renderAccountInfo(menu, payload); + } catch (_) { + renderAccountInfo(menu, accountMenuFallbackSession()); + } + } + + async function fetchAccountSession() { + try { + var response = await fetch('/api/auth/session', { + method: 'GET', + headers: { 'accept': 'application/json' }, + credentials: 'include' + }); + var payload = await response.json().catch(function() { return null; }); + if (!response.ok || !payload) throw new Error('session_failed_' + response.status); + return payload; + } catch (_) { + return accountMenuFallbackSession(); + } + } + + function openProfileDialog(session) { + closeProfileDialog(); + closeAccountMenu(); + var dialog = document.createElement('div'); + dialog.className = 'mnote-profile-dialog'; + dialog.setAttribute('data-testid', 'mnote-profile-dialog'); + dialog.setAttribute('role', 'dialog'); + dialog.setAttribute('aria-modal', 'true'); + dialog.setAttribute('aria-labelledby', 'mnote-profile-dialog-title'); + var userId = escapeHtml(session.userId || 'anonymous'); + dialog.innerHTML = + '
' + + '
' + + '
' + + '
' + + '
个人信息
' + + '

个人信息

' + + '
' + + '' + + '
' + + '
' + + '
' + escapeHtml(String(session.name || session.email || session.userId || '用').slice(0, 1).toUpperCase()) + '
' + + '
' + escapeHtml(session.name || session.userId || '当前用户') + '' + escapeHtml(session.email || '未提供邮箱') + '
' + + '
' + + '
' + + '
用户名
' + escapeHtml(session.name || '未设置') + '
' + + '
邮箱
' + escapeHtml(session.email || '未提供邮箱') + '
' + + '
用户 ID
' + userId + '
' + + '
身份
' + escapeHtml(session.actorType || session.authMode || 'unknown') + '
' + + '
' + + '
'; + dialog.querySelectorAll('[data-profile-dialog-close]').forEach(function(button) { + button.addEventListener('click', function(event) { + event.preventDefault(); + closeProfileDialog(); + }); + }); + var copyButton = dialog.querySelector('[data-profile-copy-user-id]'); + if (copyButton) { + copyButton.addEventListener('click', function(event) { + event.preventDefault(); + void copyTreeContextValue(copyButton.getAttribute('data-copy-value') || '', 'profile-user-id'); + }); + } + document.body.appendChild(dialog); + var closeButton = dialog.querySelector('.mnote-profile-dialog__close'); + if (closeButton instanceof HTMLElement) closeButton.focus(); + } + + function openAdminAccessPolicyDialog(trigger) { + closeAdminAccessPolicyDialog(); + closeAccountMenu(); + if (!(trigger instanceof HTMLElement)) return; + var role = trigger.getAttribute('data-access-policy-role') === 'admin' ? 'admin' : 'user'; + var template = document.querySelector('[data-testid="mnote-admin-access-policy-template-' + role + '"]'); + var dialog = document.createElement('div'); + dialog.className = 'mnote-admin-policy-modal'; + dialog.setAttribute('data-testid', 'mnote-admin-access-policy-modal'); + dialog.setAttribute('role', 'dialog'); + dialog.setAttribute('aria-modal', 'true'); + dialog.setAttribute('aria-labelledby', 'mnote-admin-policy-dialog-title'); + dialog.innerHTML = + '
' + + '
' + + '' + + '
' + + '
'; + var content = dialog.querySelector('[data-testid="mnote-admin-access-policy-modal-content"]'); + if (content) { + content.innerHTML = template ? template.innerHTML : '
授权管理面板加载失败
'; + } + if (typeof window.MNOTEInitAccessPolicyPanel === 'function') { + window.MNOTEInitAccessPolicyPanel(dialog); + } + dialog.querySelectorAll('[data-admin-access-policy-close]').forEach(function(button) { + button.addEventListener('click', function(event) { + event.preventDefault(); + closeAdminAccessPolicyDialog(); + }); + }); + document.body.appendChild(dialog); + document.documentElement.setAttribute('data-mnote-admin-access-policy-modal-open', 'true'); + var closeButton = dialog.querySelector('.mnote-admin-policy-modal__close'); + if (closeButton instanceof HTMLElement) closeButton.focus(); + } + + async function signOutAccount(trigger) { + setCommandPending(trigger, true); + try { + var response = await fetch('/api/auth', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ action: 'auth:signOut', args: {} }) + }); + var payload = await response.json().catch(function() { return {}; }); + payload = payload || {}; + if (!response.ok || payload.error) { + throw new Error(payload.error || '退出登录失败'); + } + window.location.assign('/auth'); + } catch (error) { + var menu = document.querySelector('[data-testid="mnote-account-menu"]'); + var errorNode = menu ? menu.querySelector('[data-account-error]') : null; + if (errorNode instanceof HTMLElement) { + errorNode.textContent = error && error.message ? error.message : '退出登录失败'; + errorNode.hidden = false; + } + setCommandPending(trigger, false); + } + } + + function openAccountMenu(trigger) { + closeAccountMenu(); + if (!(trigger instanceof HTMLElement)) return; + trigger.setAttribute('aria-expanded', 'true'); + var menu = document.createElement('div'); + menu.className = 'mnote-workspace-source-menu mnote-account-menu'; + menu.setAttribute('data-testid', 'mnote-account-menu'); + menu.setAttribute('role', 'menu'); + menu.innerHTML = + '' + + '' + + '' + + ''; + var sessionPromise = fetchAccountSession(); + var profileButton = menu.querySelector('[data-testid="mnote-account-profile"]'); + if (profileButton) { + profileButton.addEventListener('click', function(event) { + event.preventDefault(); + sessionPromise.then(openProfileDialog); + }); + } + var accessPolicyLink = menu.querySelector('[data-testid="mnote-account-access-policy"]'); + sessionPromise.then(function(session) { + if (!(accessPolicyLink instanceof HTMLElement)) return; + accessPolicyLink.hidden = false; + accessPolicyLink.setAttribute('data-access-policy-role', sessionIsAdmin(session) ? 'admin' : 'user'); + }); + if (accessPolicyLink) { + accessPolicyLink.addEventListener('click', function(event) { + event.preventDefault(); + openAdminAccessPolicyDialog(accessPolicyLink); + }); + } + var signOutButton = menu.querySelector('[data-testid="mnote-account-sign-out"]'); + if (signOutButton) { + signOutButton.addEventListener('click', function(event) { + event.preventDefault(); + void signOutAccount(signOutButton); + }); + } + var host = trigger.closest('[data-testid="wolai-sidebar-quick-actions"]') || trigger.parentElement; + if (host) host.appendChild(menu); + } + + + return { + activeSidebarTreeMode, + autoOpenRecentLocalRootOnHome, + closeAccountMenu, + closeLocalFolderDialog, + closeTrashModal, + closeWorkspaceSourceMenu, + copyWorkspaceSourceParams, + createDefaultLocalWorkspace, + currentRootUri, + currentSourceKind, + currentWorkspaceId, + currentWorkspaceSourcePayload, + normalizeSidebarTreeMode, + openAccountMenu, + openTrashModal, + openWorkspaceSourceMenu, + persistSidebarTreeMode, + readStoredSidebarTreeMode, + requestOpenLocalFolder, + resolveWorkspaceId, + }; +}; diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index 91f9354f..fc93340b 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -126,6 +126,10 @@ pub fn build_router(state: AppState) -> Router { "/api/mnote-browser-runtime/sidebar-shell-runtime.js", get(web_shell::sidebar_shell_runtime_asset), ) + .route( + "/api/mnote-browser-runtime/sidebar-workspace-runtime.js", + get(web_shell::sidebar_workspace_runtime_asset), + ) .route( "/api/mnote-browser-runtime/sidebar-tree-runtime.js", get(web_shell::sidebar_tree_runtime_asset), @@ -579,6 +583,7 @@ mod tests { "/api/mnote-browser-runtime/filetree-dnd-runtime.js", "/api/mnote-browser-runtime/filetree-keyboard-runtime.js", "/api/mnote-browser-runtime/sidebar-shell-runtime.js", + "/api/mnote-browser-runtime/sidebar-workspace-runtime.js", "/api/mnote-browser-runtime/sidebar-tree-runtime.js", "/api/mnote-browser-runtime/tree-live-controller.js", "/api/mnote-browser-runtime/tree-shell-runtime.js", diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index 530a716e..cb711a00 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -742,6 +742,20 @@ pub async fn sidebar_shell_runtime_asset() -> Response { .unwrap_or_else(|_| Response::new(Body::empty())) } +pub async fn sidebar_workspace_runtime_asset() -> Response { + const JS: &str = include_str!("../../browser/sidebar-workspace-runtime.js"); + Response::builder() + .status(StatusCode::OK) + .header( + header::CONTENT_TYPE, + "application/javascript; charset=utf-8", + ) + .header(header::CACHE_CONTROL, "no-store") + .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .body(Body::from(JS)) + .unwrap_or_else(|_| Response::new(Body::empty())) +} + pub async fn filetree_keyboard_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/filetree-keyboard-runtime.js"); Response::builder() diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index b0730101..375fbe50 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -201,6 +201,8 @@ mod tests { const SIDEBAR_TREE_RUNTIME_JS: &str = include_str!("../../../browser/sidebar-tree-runtime.js"); const SIDEBAR_SHELL_RUNTIME_JS: &str = include_str!("../../../browser/sidebar-shell-runtime.js"); + const SIDEBAR_WORKSPACE_RUNTIME_JS: &str = + include_str!("../../../browser/sidebar-workspace-runtime.js"); const FILETREE_CONTEXT_MENU_RUNTIME_JS: &str = include_str!("../../../browser/filetree-context-menu-runtime.js"); const FILETREE_DND_RUNTIME_JS: &str = include_str!("../../../browser/filetree-dnd-runtime.js"); @@ -301,18 +303,18 @@ mod tests { assert!(SIDEBAR_TREE_RUNTIME_JS.contains("sidebarFileTreeClipboard")); assert!(SIDEBAR_TREE_RUNTIME_JS.contains("pasteSidebarFileTreeClipboard")); assert!(SIDEBAR_TREE_RUNTIME_JS.contains("MNOTE_RECENT_LOCAL_ROOTS_KEY_PREFIX")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function recentLocalRootsStorageKey")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-mnote-actor-id")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("requestOpenLocalFolder")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("autoOpenRecentLocalRootOnHome")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("sourceKind', 'local_folder")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("switchToCloudWorkspace")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("mnote-switch-cloud-workspace")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("mnote-recent-local-root")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("mnote-local-folder-authorized-roots")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("mnote-local-folder-authorized-root")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("已授权文件夹")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("fetch('/api/user/access-policy'")); + assert!(SIDEBAR_WORKSPACE_RUNTIME_JS.contains("function recentLocalRootsStorageKey")); + assert!(SIDEBAR_WORKSPACE_RUNTIME_JS.contains("data-mnote-actor-id")); + assert!(SIDEBAR_WORKSPACE_RUNTIME_JS.contains("requestOpenLocalFolder")); + assert!(SIDEBAR_WORKSPACE_RUNTIME_JS.contains("autoOpenRecentLocalRootOnHome")); + assert!(SIDEBAR_WORKSPACE_RUNTIME_JS.contains("sourceKind', 'local_folder")); + assert!(SIDEBAR_WORKSPACE_RUNTIME_JS.contains("switchToCloudWorkspace")); + assert!(SIDEBAR_WORKSPACE_RUNTIME_JS.contains("mnote-switch-cloud-workspace")); + assert!(SIDEBAR_WORKSPACE_RUNTIME_JS.contains("mnote-recent-local-root")); + assert!(SIDEBAR_WORKSPACE_RUNTIME_JS.contains("mnote-local-folder-authorized-roots")); + assert!(SIDEBAR_WORKSPACE_RUNTIME_JS.contains("mnote-local-folder-authorized-root")); + assert!(SIDEBAR_WORKSPACE_RUNTIME_JS.contains("已授权文件夹")); + assert!(SIDEBAR_WORKSPACE_RUNTIME_JS.contains("fetch('/api/user/access-policy'")); } #[test] @@ -338,12 +340,12 @@ mod tests { #[test] fn page_ai_local_source_passes_file_reference_fields_to_agent_run() { - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function currentRootUri()")); + assert!(SIDEBAR_WORKSPACE_RUNTIME_JS.contains("function currentRootUri()")); assert!(SIDEBAR_TREE_RUNTIME_JS.contains("sourceKind: currentSourceKind()")); assert!(SIDEBAR_TREE_RUNTIME_JS.contains("rootUri: currentRootUri()")); - assert!(SIDEBAR_TREE_RUNTIME_JS + assert!(SIDEBAR_WORKSPACE_RUNTIME_JS .contains("if (sourceKind && !payload.sourceKind) payload.sourceKind = sourceKind")); - assert!(SIDEBAR_TREE_RUNTIME_JS + assert!(SIDEBAR_WORKSPACE_RUNTIME_JS .contains("if (rootUri && !payload.rootUri) payload.rootUri = rootUri")); assert!(SIDEBAR_TREE_RUNTIME_JS.contains("pageContext: scopedContext.pageContext")); assert!( @@ -449,15 +451,15 @@ mod tests { "进入本地文件夹前不能用 document.body 推导 workspaceId;它会在无 workspaceId URL 时回退成 default" ); assert!( - SIDEBAR_TREE_RUNTIME_JS.contains("rememberCurrentCloudWorkspaceId()"), + SIDEBAR_WORKSPACE_RUNTIME_JS.contains("rememberCurrentCloudWorkspaceId()"), "进入本地文件夹前应从 URL 或 DOM 子树读取真实 workspaceId" ); assert!( - SIDEBAR_TREE_RUNTIME_JS.contains("normalized.indexOf('local-ws:') === 0"), + SIDEBAR_WORKSPACE_RUNTIME_JS.contains("normalized.indexOf('local-ws:') === 0"), "本地文件夹 workspaceId 不能写入 lastCloudWorkspaceId" ); assert!( - SIDEBAR_TREE_RUNTIME_JS.contains("stored.trim().indexOf('local-ws:') !== 0"), + SIDEBAR_WORKSPACE_RUNTIME_JS.contains("stored.trim().indexOf('local-ws:') !== 0"), "切回工作区时不能复用 local-ws:* 作为旧云 workspace" ); assert!( @@ -471,7 +473,7 @@ mod tests { fn sidebar_tree_runtime_polls_local_folder_without_browser_reload() { assert!(SIDEBAR_TREE_RUNTIME_JS.contains("startLocalFolderSidebarWatch")); assert!(SIDEBAR_TREE_RUNTIME_JS.contains("refreshLocalFolderSidebarSnapshot")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function currentWorkspaceId()")); + assert!(SIDEBAR_WORKSPACE_RUNTIME_JS.contains("function currentWorkspaceId()")); assert!(SIDEBAR_TREE_RUNTIME_JS.contains("var rootUri = currentRootUri();")); assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/api/tree/local-folder-watch")); assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/api/tree/projections/sidebar")); @@ -791,21 +793,22 @@ mod tests { #[test] fn sidebar_local_folder_authorized_root_normalizes_plain_path_root_uri() { - assert!( - SIDEBAR_TREE_RUNTIME_JS.contains("function normalizeGrantedLocalFolderRootUri(grant)") - ); - assert!( - SIDEBAR_TREE_RUNTIME_JS.contains("if (rootPath) return pathToFileRootUri(rootPath);") - ); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("if (rootUri) return pathToFileRootUri(rootUri);")); - assert!(SIDEBAR_TREE_RUNTIME_JS + assert!(SIDEBAR_WORKSPACE_RUNTIME_JS + .contains("function normalizeGrantedLocalFolderRootUri(grant)")); + assert!(SIDEBAR_WORKSPACE_RUNTIME_JS + .contains("if (rootPath) return pathToFileRootUri(rootPath);")); + assert!(SIDEBAR_WORKSPACE_RUNTIME_JS + .contains("if (rootUri) return pathToFileRootUri(rootUri);")); + assert!(SIDEBAR_WORKSPACE_RUNTIME_JS .contains("var rootUri = normalizeGrantedLocalFolderRootUri(grant);")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function isDefaultWorkspaceAutoGrant(grant)")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("if (isDefaultWorkspaceAutoGrant(grant)) return;")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function recentLocalRootLabel(rootUri)")); assert!( - SIDEBAR_TREE_RUNTIME_JS.contains("button.textContent = recentLocalRootLabel(rootUri);") + SIDEBAR_WORKSPACE_RUNTIME_JS.contains("function isDefaultWorkspaceAutoGrant(grant)") ); + assert!(SIDEBAR_WORKSPACE_RUNTIME_JS + .contains("if (isDefaultWorkspaceAutoGrant(grant)) return;")); + assert!(SIDEBAR_WORKSPACE_RUNTIME_JS.contains("function recentLocalRootLabel(rootUri)")); + assert!(SIDEBAR_WORKSPACE_RUNTIME_JS + .contains("button.textContent = recentLocalRootLabel(rootUri);")); assert!(!SIDEBAR_TREE_RUNTIME_JS .contains("button.textContent = rootUri.replace(/^file:\\/\\//, '')")); }