feat: vault core/CLI/workbench, vaultd token path, filetree view-state cleanup

Land password-vault dedicated workbench and mnote-vault-core/CLI, agent token
read path design, vault transport split, and retire obsolete filetree smokes.
Ignore local vault reimport scripts that trip secret scanners.
This commit is contained in:
Agent Board
2026-07-24 11:36:06 +08:00
parent b798f628ee
commit bc6f8488ee
41 changed files with 13072 additions and 2316 deletions
+1
View File
@@ -16,6 +16,7 @@ hyper = "1"
hyper-util = { version = "0.1", features = ["tokio"] }
leptos = { version = "0.8.14", default-features = false, features = ["ssr"] }
mnote-editor-core = { path = "../mnote-editor-core" }
mnote-vault-core = { path = "../mnote-vault-core" }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "multipart", "rustls-tls", "stream"] }
rusqlite = { version = "0.34", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
@@ -11,6 +11,7 @@ var FILETREE_DRAG_MIME = 'application/x-mnote-file-tree';
var draggingFileTreeRowIds = [];
var activeFileTreeDropRow = null;
var activeFileTreeDropPosition = null;
// ─── 纯 helper ─────────────────────────────────────────
@@ -22,10 +23,34 @@ function filetreeDragPayload(rowIds) {
});
}
/**
* Wolai-style drop zones on a row:
* - top 25% → before (sibling reorder line)
* - bottom 25% → after
* - middle → inside folders/dirs; leaf rows treat middle as after
*/
function fileTreeDropPosition(event, row) {
if (!(row instanceof HTMLElement) || !event) return 'inside';
var rect = row.getBoundingClientRect();
var ratio = rect.height > 0 ? (event.clientY - rect.top) / rect.height : 0.5;
if (ratio < 0.25) return 'before';
if (ratio > 0.75) return 'after';
var kind = String(row.getAttribute('data-row-kind') || '').trim();
if (kind === 'folder' || kind === 'directory' || kind === 'doc' || kind === 'document') {
// documents can host children in page/file tree hybrid; folders always accept inside
if (kind === 'folder' || kind === 'directory') return 'inside';
// expandable document rows also accept nest-into
if (row.getAttribute('aria-expanded') != null && row.querySelector('.tree-toggle')) return 'inside';
}
// non-container leaf: middle zone still sorts after the row
return 'after';
}
function filetreeDropDetail(targetRow, fileTree, deps) {
deps = deps || {};
var resolveWorkspaceId = deps.resolveWorkspaceId || function() { return ''; };
var fileTreeRowLocalUploadTargetRelativePath = deps.fileTreeRowLocalUploadTargetRelativePath || function() { return ''; };
var dropPosition = deps.dropPosition || null;
return {
workspaceId: resolveWorkspaceId(targetRow || fileTree),
targetRowId: targetRow ? targetRow.getAttribute('data-row-id') : null,
@@ -36,6 +61,7 @@ function filetreeDropDetail(targetRow, fileTree, deps) {
assetId: targetRow ? targetRow.getAttribute('data-asset-id') : null,
targetRelativePath: targetRow ? fileTreeRowLocalUploadTargetRelativePath(targetRow) : '',
uploadIntent: 'filetree.folder.drop',
dropPosition: dropPosition || (targetRow ? 'inside' : 'inside'),
};
}
@@ -59,8 +85,21 @@ function parseFileTreeDragPayload(raw) {
function clearFileTreeDropFeedback() {
if (activeFileTreeDropRow instanceof HTMLElement) {
activeFileTreeDropRow.setAttribute('data-drop-target', 'false');
activeFileTreeDropRow.removeAttribute('data-drop-position');
activeFileTreeDropRow.setAttribute('data-drop-feedback', 'false');
}
activeFileTreeDropRow = null;
activeFileTreeDropPosition = null;
}
function setFileTreeDropFeedback(row, position) {
clearFileTreeDropFeedback();
if (!(row instanceof HTMLElement)) return;
activeFileTreeDropRow = row;
activeFileTreeDropPosition = position || 'inside';
activeFileTreeDropRow.setAttribute('data-drop-target', 'true');
activeFileTreeDropRow.setAttribute('data-drop-feedback', 'true');
activeFileTreeDropRow.setAttribute('data-drop-position', activeFileTreeDropPosition);
}
function resetFileTreeDragState() {
@@ -95,10 +134,27 @@ function handleFileTreeDragOver(event, deps) {
if (!hasFiles && !hasInternal && draggingFileTreeRowIds.length === 0) return false;
event.preventDefault();
clearFileTreeDropFeedback();
activeFileTreeDropRow = closestAction(event.target, '.tree-row[data-shell-mode="filetree"]') || fileTree;
if (activeFileTreeDropRow instanceof HTMLElement) {
activeFileTreeDropRow.setAttribute('data-drop-target', 'true');
var row = closestAction(event.target, '.tree-row[data-shell-mode="filetree"]');
if (row instanceof HTMLElement) {
var position = hasFiles ? 'inside' : fileTreeDropPosition(event, row);
// external file drops only nest into containers
if (hasFiles) {
var kind = String(row.getAttribute('data-row-kind') || '').trim();
if (kind !== 'folder' && kind !== 'directory' && kind !== 'doc' && kind !== 'document') {
// drop onto leaf file → treat as after (parent folder context resolved at drop)
position = 'after';
} else if (kind === 'folder' || kind === 'directory') {
position = 'inside';
}
}
setFileTreeDropFeedback(row, position);
} else {
clearFileTreeDropFeedback();
activeFileTreeDropRow = fileTree;
if (activeFileTreeDropRow instanceof HTMLElement) {
activeFileTreeDropRow.setAttribute('data-drop-target', 'true');
}
activeFileTreeDropPosition = 'inside';
}
if (event.dataTransfer) {
var copyModifier = event.altKey || event.ctrlKey || event.metaKey;
@@ -134,9 +190,16 @@ function handleFileTreeDrop(event, deps) {
if (!files.length && !rowIds.length) return;
event.preventDefault();
var dropPosition = activeFileTreeDropPosition
|| (targetRow ? fileTreeDropPosition(event, targetRow) : 'inside');
if (files.length) {
var fileKind = targetRow ? String(targetRow.getAttribute('data-row-kind') || '').trim() : '';
if (fileKind === 'folder' || fileKind === 'directory') dropPosition = 'inside';
}
var detail = filetreeDropDetail(targetRow, fileTree, {
resolveWorkspaceId: resolveWorkspaceId,
fileTreeRowLocalUploadTargetRelativePath: fileTreeRowLocalUploadTargetRelativePath,
dropPosition: dropPosition,
});
clearFileTreeDropFeedback();
if (files.length) {
@@ -149,6 +212,7 @@ function handleFileTreeDrop(event, deps) {
dispatchSidebarEvent('tree.filetree.internal-drop', Object.assign({}, detail, {
rowIds: rowIds,
copy: copyModifier,
dropPosition: dropPosition,
}));
});
}
@@ -159,14 +223,18 @@ function handleFileTreeDrop(event, deps) {
window.__mnoteFileTreeDndRuntime = {
FILETREE_DRAG_MIME: FILETREE_DRAG_MIME,
draggingFileTreeRowIds: draggingFileTreeRowIds,
activeFileTreeDropRow: activeFileTreeDropRow,
get draggingFileTreeRowIds() { return draggingFileTreeRowIds; },
set draggingFileTreeRowIds(value) { draggingFileTreeRowIds = value; },
get activeFileTreeDropRow() { return activeFileTreeDropRow; },
get activeFileTreeDropPosition() { return activeFileTreeDropPosition; },
filetreeDragPayload: filetreeDragPayload,
filetreeDropDetail: filetreeDropDetail,
fileTreeDropPosition: fileTreeDropPosition,
filetreeHasFiles: filetreeHasFiles,
filetreeHasInternalDrag: filetreeHasInternalDrag,
parseFileTreeDragPayload: parseFileTreeDragPayload,
clearFileTreeDropFeedback: clearFileTreeDropFeedback,
setFileTreeDropFeedback: setFileTreeDropFeedback,
resetFileTreeDragState: resetFileTreeDragState,
startFileTreeDrag: startFileTreeDrag,
handleFileTreeDragOver: handleFileTreeDragOver,
@@ -10,15 +10,22 @@ function getSidebarFileTreeClipboard() {
return sidebarFileTreeClipboard;
}
function setSidebarFileTreeClipboard(action, rowIds) {
function setSidebarFileTreeClipboard(action, rowIds, options) {
sidebarFileTreeClipboard = { action: action, rowIds: rowIds };
document.documentElement.setAttribute('data-mnote-filetree-clipboard-action', action);
// Keep product runtimeState / host clipboard in sync (menu paste reads runtimeState).
if (options && typeof options.onClipboardChange === 'function') {
options.onClipboardChange(sidebarFileTreeClipboard);
}
return sidebarFileTreeClipboard;
}
function clearSidebarFileTreeClipboard() {
function clearSidebarFileTreeClipboard(options) {
sidebarFileTreeClipboard = null;
document.documentElement.removeAttribute('data-mnote-filetree-clipboard-action');
if (options && typeof options.onClipboardChange === 'function') {
options.onClipboardChange(null);
}
}
// ─── Keyboard handler ───────────────────────────────────
@@ -31,6 +38,7 @@ function handleFileTreeKeyDown(event, deps) {
var buildSidebarFileTreeContext = deps.buildSidebarFileTreeContext || function() { return {}; };
var evaluateSidebarFileTreeWhen = deps.evaluateSidebarFileTreeWhen || function() { return false; };
var deleteSelectedSidebarFileTreeRows = deps.deleteSelectedSidebarFileTreeRows || function() { return Promise.resolve(); };
var onClipboardChange = deps.onClipboardChange || null;
var keyTarget = event.target;
var fileTreeRootForKey = document.getElementById('sidebar-file-tree-root');
@@ -59,7 +67,9 @@ function handleFileTreeKeyDown(event, deps) {
}).filter(Boolean);
if (selectedRowIds.length > 0) {
event.preventDefault();
setSidebarFileTreeClipboard(shortcutKey === 'x' ? 'cut' : 'copy', selectedRowIds);
setSidebarFileTreeClipboard(shortcutKey === 'x' ? 'cut' : 'copy', selectedRowIds, {
onClipboardChange: onClipboardChange,
});
}
return true;
}
@@ -92,7 +102,7 @@ function handleFileTreeKeyDown(event, deps) {
// ─── 导出 ───────────────────────────────────────────────
window.__mnoteFileTreeKeyboardRuntime = {
sidebarFileTreeClipboard: sidebarFileTreeClipboard,
get sidebarFileTreeClipboard() { return sidebarFileTreeClipboard; },
getSidebarFileTreeClipboard: getSidebarFileTreeClipboard,
setSidebarFileTreeClipboard: setSidebarFileTreeClipboard,
clearSidebarFileTreeClipboard: clearSidebarFileTreeClipboard,
@@ -183,6 +183,49 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
runtimeState.activeTreeContextMenu = null;
}
/**
* Product clipboard used by menu paste + keyboard paste.
* Keep keyboard-module clipboard and runtimeState in lockstep so
* 「粘贴到此处」enables after either Ctrl+C/X or menu cut/copy.
*/
function currentSidebarFileTreeClipboard() {
if (runtimeState.sidebarFileTreeClipboard
&& Array.isArray(runtimeState.sidebarFileTreeClipboard.rowIds)
&& runtimeState.sidebarFileTreeClipboard.rowIds.length) {
return runtimeState.sidebarFileTreeClipboard;
}
var kb = window.__mnoteFileTreeKeyboardRuntime;
var fromKb = kb && typeof kb.getSidebarFileTreeClipboard === 'function'
? kb.getSidebarFileTreeClipboard()
: (kb && kb.sidebarFileTreeClipboard) || null;
if (fromKb && Array.isArray(fromKb.rowIds) && fromKb.rowIds.length) {
// Heal dual-clipboard drift (keyboard module wrote first).
runtimeState.sidebarFileTreeClipboard = fromKb;
return fromKb;
}
return null;
}
function setSidebarFileTreeClipboard(action, rowIds) {
var next = action && Array.isArray(rowIds) && rowIds.length
? { action: action, rowIds: rowIds.slice() }
: null;
runtimeState.sidebarFileTreeClipboard = next;
var kb = window.__mnoteFileTreeKeyboardRuntime;
if (kb && typeof kb.setSidebarFileTreeClipboard === 'function') {
if (next) {
kb.setSidebarFileTreeClipboard(next.action, next.rowIds);
} else if (typeof kb.clearSidebarFileTreeClipboard === 'function') {
kb.clearSidebarFileTreeClipboard();
}
} else if (next) {
document.documentElement.setAttribute('data-mnote-filetree-clipboard-action', next.action);
} else {
document.documentElement.removeAttribute('data-mnote-filetree-clipboard-action');
}
return next;
}
function copyTreeContextValue(value, actionName) {
var text = String(value || '');
var done = function() {
@@ -618,6 +661,30 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
});
return;
}
if (action === 'cut' || action === 'copy') {
var clipboardRows = selectedSidebarFileTreeRows();
if (!clipboardRows.length && trigger && trigger.closest) {
var single = trigger.closest('.tree-row[data-shell-mode="filetree"]');
if (single instanceof HTMLElement) clipboardRows = [single];
}
var clipboardRowIds = clipboardRows.map(function(row) {
return String(row.getAttribute('data-row-id') || '').trim();
}).filter(Boolean);
if (!clipboardRowIds.length) {
recordFileTreeActionStatus('skipped', Object.assign({}, detail, { reason: 'empty-selection' }));
return;
}
setSidebarFileTreeClipboard(action, clipboardRowIds);
recordFileTreeAction(action, Object.assign({}, detail, {
sourceRowIds: clipboardRowIds,
clipboardAction: action,
}));
recordFileTreeActionStatus('applied', Object.assign({}, detail, {
sourceRowIds: clipboardRowIds,
clipboardAction: action,
}));
return;
}
if (action === 'paste-into') {
var pasteRow = trigger && trigger.closest ? trigger.closest('.tree-row[data-shell-mode="filetree"]') : trigger;
recordFileTreeAction('paste-into', detail);
@@ -1228,7 +1295,10 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
{ action: 'new-file', icon: 'note_add', label: 'New File', when: '!workspace.readonly' },
{ action: 'new-folder', icon: 'create_new_folder', label: 'New Folder', disabled: currentSourceKind() !== 'local_folder', title: currentSourceKind() === 'local_folder' ? '在当前目录下创建子文件夹' : '仅 local folder 支持创建文件夹', when: '!workspace.readonly' },
{ action: 'toggle-sidebar-folder-shortcut', icon: 'star', label: '加入/取消星标置顶', disabled: currentSourceKind() !== 'local_folder' || (detail.rowKind !== 'folder' && detail.rowKind !== 'directory'), title: currentSourceKind() === 'local_folder' ? '把当前文件夹加入或移出星标置顶' : '仅 local folder 文件夹支持星标置顶' },
{ action: 'paste-into', icon: 'content_paste', label: '粘贴到此处', disabled: !runtimeState.sidebarFileTreeClipboard, title: runtimeState.sidebarFileTreeClipboard ? '粘贴到当前文件树目标' : '剪贴板为空', when: '!workspace.readonly' },
{ separator: true },
{ action: 'cut', icon: 'content_cut', label: '剪切', shortcut: 'Ctrl+X', when: '!workspace.readonly' },
{ action: 'copy', icon: 'content_copy', label: '复制', shortcut: 'Ctrl+C' },
{ action: 'paste-into', icon: 'content_paste', label: '粘贴到此处', shortcut: 'Ctrl+V', disabled: !currentSidebarFileTreeClipboard(), title: currentSidebarFileTreeClipboard() ? '粘贴到当前文件树目标' : '剪贴板为空', when: '!workspace.readonly' },
{ action: 'refresh', icon: 'refresh', label: 'Refresh' },
{ action: 'collapse-all', icon: 'unfold_less', label: 'Collapse All' },
{ action: 'reveal', icon: 'my_location', label: 'Reveal' },
@@ -1929,9 +1999,16 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
var runtimeFn = fileTreeRuntimeFunction('fileTreeChildCount');
if (runtimeFn) return runtimeFn(documentId, fileTreeRuntimeDeps());
if (!documentId) return 0;
var row = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape('doc:' + documentId) + '"]');
var row = document.querySelector(
'#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape('doc:' + documentId) + '"],'
+ '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(documentId) + '"],'
+ '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-document-id="' + cssEscape(documentId) + '"],'
+ '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-node-id="' + cssEscape(documentId) + '"]'
);
var node = row ? row.closest('.tree-node') : null;
var children = node ? node.querySelectorAll(':scope > .tree-children > .tree-node > .tree-row[data-shell-mode="filetree"][data-row-kind="document"], :scope > .tree-children > .tree-node > .tree-row[data-shell-mode="filetree"][data-row-kind="doc"]') : [];
var children = node
? node.querySelectorAll(':scope > .tree-children > .tree-node > .tree-row[data-shell-mode="filetree"]')
: [];
return children.length;
}
@@ -1944,6 +2021,45 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
}, targetRow) || null;
}
function fileTreeParentIdFromAttr(parentAttr) {
var raw = String(parentAttr || '').trim();
if (!raw) return null;
if (raw.indexOf('doc:') === 0) return raw.slice(4);
return raw;
}
/**
* Wolai-style move target:
* - inside → nest under target (folder/dir/doc)
* - before/after → same parent as target, sortOrder = sibling index (+1 for after)
*/
function resolveFileTreeMoveTarget(targetRow, position) {
if (!(targetRow instanceof HTMLElement)) {
return { parentId: null, sortOrder: 0 };
}
var pos = position || 'inside';
if (pos === 'inside') {
var nestParentId = fileTreeMoveTargetParentId(targetRow);
return {
parentId: nestParentId,
sortOrder: nestParentId ? fileTreeChildCount(nestParentId) : 0,
};
}
var parentAttr = targetRow.getAttribute('data-parent-id') || '';
var parentId = fileTreeParentIdFromAttr(parentAttr);
var siblings = Array.from(
document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]')
).filter(function(row) {
return (row.getAttribute('data-parent-id') || '') === parentAttr;
});
var index = siblings.indexOf(targetRow);
if (index < 0) index = 0;
return {
parentId: parentId,
sortOrder: Math.max(0, index + (pos === 'after' ? 1 : 0)),
};
}
function fileTreeMoveSourceId(row) {
if (!(row instanceof HTMLElement)) return '';
return fileTreeRowDocumentId(row)
@@ -1956,7 +2072,10 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
var rows = fileTreeRowsByRowIds(rowIds || []);
if (rows.length === 0) return false;
var copy = Boolean(options && options.copy);
var targetParentId = fileTreeMoveTargetParentId(targetRow);
var dropPosition = (options && options.dropPosition) || 'inside';
var resolved = resolveFileTreeMoveTarget(targetRow, dropPosition);
var targetParentId = resolved.parentId;
var baseSortOrder = typeof resolved.sortOrder === 'number' ? resolved.sortOrder : 0;
var workspaceId = resolveWorkspaceId(targetRow || document.body);
var writable = await ensureFileTreeWritableTarget('move', targetRow, rowIds || [], copy);
if (!writable) return false;
@@ -1983,7 +2102,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
workspaceId: workspaceId,
documentId: sourceId,
parentId: targetParentId,
sortOrder: targetParentId ? fileTreeChildCount(targetParentId) + i : i
sortOrder: baseSortOrder + i
});
} catch (error) {
failures.push(sourceId + ': ' + (error && error.message ? error.message : '移动失败'));
@@ -2014,21 +2133,24 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
}
async function pasteSidebarFileTreeClipboard(trigger) {
if (!runtimeState.sidebarFileTreeClipboard || !Array.isArray(runtimeState.sidebarFileTreeClipboard.rowIds) || runtimeState.sidebarFileTreeClipboard.rowIds.length === 0) return false;
var clipboard = currentSidebarFileTreeClipboard();
if (!clipboard) return false;
var targetRow = trigger instanceof HTMLElement ? trigger : null;
if (!targetRow && sidebarFileTreeSelection.focusedRowId) {
targetRow = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(sidebarFileTreeSelection.focusedRowId) + '"]');
}
var targetDocumentId = fileTreeMoveTargetParentId(targetRow) || currentDocumentId();
var action = runtimeState.sidebarFileTreeClipboard.action === 'cut' ? 'move' : 'copy';
var action = clipboard.action === 'cut' ? 'move' : 'copy';
recordFileTreeAction('paste', {
rowId: targetRow ? targetRow.getAttribute('data-row-id') || '' : '',
documentId: targetDocumentId,
sourceRowIds: runtimeState.sidebarFileTreeClipboard.rowIds,
clipboardAction: runtimeState.sidebarFileTreeClipboard.action
sourceRowIds: clipboard.rowIds,
clipboardAction: clipboard.action
});
var ok = await moveSidebarFileTreeRows(runtimeState.sidebarFileTreeClipboard.rowIds, targetRow, { copy: action === 'copy' });
if (ok && action === 'move') runtimeState.sidebarFileTreeClipboard = null;
var ok = await moveSidebarFileTreeRows(clipboard.rowIds, targetRow, { copy: action === 'copy' });
if (ok && action === 'move') {
setSidebarFileTreeClipboard(null, []);
}
return ok;
}
@@ -2206,6 +2328,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
postSidebarFileTreeJson,
fileTreeRowsByRowIds,
fileTreeChildCount,
resolveFileTreeMoveTarget,
moveSidebarFileTreeRows,
pasteSidebarFileTreeClipboard,
deleteSelectedSidebarFileTreeRows,
@@ -186,12 +186,29 @@ export const createSidebarPageTreeRuntime = (dependencies = {}) => {
function clearPageDropFeedback() {
if (activePageDropRow instanceof HTMLElement) {
activePageDropRow.setAttribute('data-drop-feedback', 'false');
activePageDropRow.setAttribute('data-drop-target', 'false');
activePageDropRow.removeAttribute('data-drop-position');
}
activePageDropRow = null;
}
function setActivePageDropRow(row) {
activePageDropRow = row instanceof HTMLElement ? row : null;
function setActivePageDropRow(row, position) {
if (!(row instanceof HTMLElement)) {
clearPageDropFeedback();
return;
}
if (activePageDropRow && activePageDropRow !== row) {
clearPageDropFeedback();
}
activePageDropRow = row;
activePageDropRow.setAttribute('data-drop-feedback', 'true');
activePageDropRow.setAttribute('data-drop-target', 'true');
// Always refresh position so before↔after on the same row updates the edge line.
if (position) {
activePageDropRow.setAttribute('data-drop-position', position);
} else {
activePageDropRow.removeAttribute('data-drop-position');
}
}
function clearPageDragState() {
@@ -522,6 +522,11 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
'<div class="mnote-vault-header-actions">' +
'<button type="button" data-vault-create data-testid="vault-create">新建</button>' +
'<button type="button" data-vault-cipher-book data-testid="vault-cipher-book">密文簿</button>' +
'<label class="mnote-vault-insert-cipher" title="在当前焦点字段光标处插入 [Key]" hidden aria-hidden="true">' +
'<span class="mnote-vault-sr-only">插入密文</span>' +
'<select data-vault-insert-cipher data-testid="vault-insert-cipher" aria-label="插入密文">' +
'<option value="">插入密文…</option>' +
'</select></label>' +
'<input type="search" data-vault-search data-testid="vault-search" placeholder="搜索标题、用户名、标签、分组…" aria-label="搜索密码条目" />' +
'<div class="mnote-vault-tabs" role="tablist">' +
'<button type="button" role="tab" data-vault-tab="active" class="is-active" aria-selected="true">在用</button>' +
@@ -3698,12 +3703,17 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
var targetRow = detail.targetRowId
? document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(detail.targetRowId) + '"]')
: null;
var dropPosition = detail.dropPosition || 'inside';
recordFileTreeAction('internal-drop', {
rowId: detail.targetRowId || '',
sourceRowIds: rowIds,
copy: Boolean(detail.copy)
copy: Boolean(detail.copy),
dropPosition: dropPosition
});
void moveSidebarFileTreeRows(rowIds, targetRow, {
copy: Boolean(detail.copy),
dropPosition: dropPosition,
});
void moveSidebarFileTreeRows(rowIds, targetRow, { copy: Boolean(detail.copy) });
});
document.addEventListener('contextmenu', function(event) {
@@ -3746,6 +3756,9 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
evaluateSidebarFileTreeWhen: evaluateSidebarFileTreeWhen,
deleteSelectedSidebarFileTreeRows: deleteSelectedSidebarFileTreeRows,
pasteSidebarFileTreeClipboard: pasteSidebarFileTreeClipboard,
onClipboardChange: function(nextClipboard) {
sidebarFileTreeClipboard = nextClipboard;
},
});
if (handled) return;
} else {
@@ -3953,10 +3966,8 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
var sourceNodeId = readPageDragNodeId(event);
if (pageRow && canDropPage(sourceNodeId, pageRow)) {
event.preventDefault();
clearPageDropFeedback();
pageRow.setAttribute('data-drop-feedback', 'true');
pageRow.setAttribute('data-drop-position', pageDropPosition(event, pageRow));
setActivePageDropRow(pageRow);
var pagePosition = pageDropPosition(event, pageRow);
setActivePageDropRow(pageRow, pagePosition);
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
return;
}
File diff suppressed because it is too large Load Diff
+22
View File
@@ -92,6 +92,28 @@ impl WebError {
}
}
/// Map independent vault-core errors into HTTP WebError (12-2 P1a shared read path).
impl From<mnote_vault_core::VaultError> for WebError {
fn from(err: mnote_vault_core::VaultError) -> Self {
use mnote_vault_core::VaultStatus;
let status = match err.status {
VaultStatus::BadRequest => StatusCode::BAD_REQUEST,
VaultStatus::Unauthorized => StatusCode::UNAUTHORIZED,
VaultStatus::Forbidden => StatusCode::FORBIDDEN,
VaultStatus::NotFound => StatusCode::NOT_FOUND,
VaultStatus::Conflict => StatusCode::CONFLICT,
VaultStatus::Locked => StatusCode::from_u16(423).unwrap_or(StatusCode::FORBIDDEN),
VaultStatus::Unavailable => StatusCode::SERVICE_UNAVAILABLE,
VaultStatus::Internal => StatusCode::INTERNAL_SERVER_ERROR,
};
let mut web = WebError::new(status, err.code, err.message);
if let Some(details) = err.details {
web = web.with_details(details);
}
web
}
}
impl IntoResponse for WebError {
fn into_response(self) -> Response {
let body = ErrorBody {
@@ -131,7 +131,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
MnoteCapabilityPack {
id: "mnote-vault",
title: "密码箱 / AI 密码本",
description: "密码箱与 AI 密码本使用约定:禁止通用文件工具读 .mnote/vault;凭证经 vault API / 共享到 AI 密码本",
description: "密码箱与 AI 密码本:读密/login/session 用 mnote-vault CLI 或 Pi mnote.vault.*token+core/UDS,不依赖 3000);禁止通用文件工具读 .mnote/vault。",
category: "security",
agent_ids: &["hermes", "reasonix"],
read_only: true,
@@ -1419,6 +1419,12 @@ fn render_vault_workbench_html(workspace_id: &str, root_uri: &str, bootstrap: &V
<div class="mnote-vault-header-actions">
<button type="button" data-vault-create data-testid="vault-create"></button>
<button type="button" data-vault-cipher-book data-testid="vault-cipher-book">簿</button>
<label class="mnote-vault-insert-cipher" title="在当前焦点字段光标处插入 [Key]" hidden aria-hidden="true">
<span class="mnote-vault-sr-only"></span>
<select data-vault-insert-cipher data-testid="vault-insert-cipher" aria-label="插入密文">
<option value=""></option>
</select>
</label>
<input type="search" data-vault-search data-testid="vault-search" placeholder="搜索标题、用户名、标签、分组…" aria-label="搜索密码条目" />
<div class="mnote-vault-tabs" role="tablist">
<button type="button" role="tab" data-vault-tab="active" class="is-active" aria-selected="true"></button>
@@ -189,7 +189,10 @@ fn local_page_tree_snapshot_scan_test_loads_for_key(
root_uri: &str,
parent_relative_path: &str,
) -> u64 {
let key = format!("{root_uri}\n{parent_relative_path}");
// Match load_local_folder_page_tree_snapshot_for_scope cache_key shape:
// "{root_source_uri}\n{parent_relative_path}\n{reveal_relative_path}".
// Callers pass the same root_uri used for load; reveal counters are empty here.
let key = format!("{root_uri}\n{parent_relative_path}\n");
LOCAL_PAGE_TREE_SNAPSHOT_SCAN_TEST_LOADS_BY_KEY
.get_or_init(|| Mutex::new(BTreeMap::new()))
.lock()
@@ -8554,16 +8557,17 @@ fn append_page_tree_reveal_rows(
}
let depth = local_folder_relative_depth(ancestor);
// Parent id for children of this ancestor directory.
let parent_node_id = if let Some(sibling_md) = ancestor_sibling_markdown_page_id(root, ancestor)
{
Some(sibling_md)
} else {
Some(local_directory_group_id(ancestor))
};
// Must match how shallow PageTree projects the directory itself
// (nested bundle Dir/Dir.md, sibling Name.md, or local-dir page-group).
// Previously only sibling .md was checked, so nested bundles got
// parentNodeId=local-dir:… while the parent row was local-md:…/Dir.md;
// groupRowsByParent then promoted children to roots (duplicate roots after delete/reveal).
let parent_node_id = Some(page_tree_node_id_for_directory(root, ancestor));
// Ensure the ancestor group/page row itself is expanded.
for row in rows.iter_mut() {
if row.relative_path == *ancestor
|| row.node_id == local_directory_group_id(ancestor)
|| row.node_id == parent_node_id.as_deref().unwrap_or_default()
|| row.document_id.as_deref()
== parent_node_id
.as_deref()
@@ -8628,6 +8632,31 @@ fn append_page_tree_reveal_rows(
Ok(())
}
/// PageTree node id for a directory, aligned with `scan_markdown_page_tree_shallow`:
/// 1. nested page bundle `Dir/Dir.md` → `local-md:…/Dir/Dir.md`
/// 2. sibling markdown `parent/Name.md` with directory `parent/Name/` → that page id
/// 3. otherwise page-group → `local-dir:…`
fn page_tree_node_id_for_directory(root: &Path, directory_relative: &str) -> String {
let normalized = directory_relative
.trim()
.trim_matches('/')
.replace('\\', "/");
if normalized.is_empty() {
return String::new();
}
if let Ok(directory) = resolve_metadata_relative_path(root, &normalized) {
if let Some(nested_main) = nested_bundle_main_markdown(&directory) {
if let Ok(relative) = normalize_relative_path(root, &nested_main) {
return local_markdown_path_page_id(&relative);
}
}
}
if let Some(sibling_md) = ancestor_sibling_markdown_page_id(root, &normalized) {
return sibling_md;
}
local_directory_group_id(&normalized)
}
fn ancestor_sibling_markdown_page_id(root: &Path, ancestor_relative: &str) -> Option<String> {
let parent = Path::new(ancestor_relative).parent()?;
let name = Path::new(ancestor_relative).file_name()?.to_str()?;
@@ -13953,6 +13982,38 @@ fn main() {}
let _ = std::fs::remove_dir_all(&root);
}
/// 12-2 / 12-1:通用 local file open 不得读 `.mnote/vault/**`(须走 vault API / mnote-vault)。
#[tokio::test]
async fn local_file_open_rejects_vault_system_path() {
let root = temp_root("mnote-local-file-open-vault-deny");
let vault_entry = root.join(".mnote/vault/entries");
std::fs::create_dir_all(&vault_entry).expect("vault dir");
std::fs::write(vault_entry.join("secret.md"), "password: leak").expect("write vault");
init_workspace(&root);
let root_uri = format!("file://{}", root.display());
let mut headers = HeaderMap::new();
headers.insert("x-mnote-actor-id", "user_test".parse().unwrap());
headers.insert("x-mnote-actor-type", "user".parse().unwrap());
let context = RequestContext::from_http_parts(
&Method::GET,
&"/api/local-folder/files/open".parse().expect("uri"),
&headers,
);
let query = LocalFileOpenQuery {
root_uri,
path: ".mnote/vault/entries/secret.md".into(),
download: None,
};
let error = open_local_file(State(test_state()), Extension(context), Query(query))
.await
.expect_err("must deny vault path on general file open");
assert_eq!(error.status(), StatusCode::FORBIDDEN);
assert_eq!(error.code(), "vault_path_denied");
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn local_file_open_allows_read_grant() {
let _guard = env_lock().lock().expect("env lock");
@@ -14804,6 +14865,102 @@ fn main() {}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_page_tree_reveal_nested_bundle_keeps_single_root_parent() {
// Regression: delete/watch full sidebar refresh uses reveal. Nested
// Root/Root.md must own Root/Child/Child.md via parentNodeId, not
// local-dir:Root (which groupRowsByParent promotes to duplicate roots).
let root = temp_root("mnote-page-tree-reveal-nested-bundle-parent");
init_workspace(&root);
let root_uri = format!("file://{}", root.display());
std::fs::create_dir_all(root.join("Root").join("Child")).expect("create nested dirs");
std::fs::write(root.join("Root").join("Root.md"), "# Root\n").expect("write Root.md");
std::fs::write(
root.join("Root").join("Child").join("Child.md"),
"# Child\n",
)
.expect("write Child.md");
// Second sibling under Root so multi-child root promotion would be obvious.
std::fs::create_dir_all(root.join("Root").join("Sibling")).expect("create Sibling");
std::fs::write(
root.join("Root").join("Sibling").join("Sibling.md"),
"# Sibling\n",
)
.expect("write Sibling.md");
let reveal_doc = local_markdown_path_page_id("Root/Child/Child.md");
assert_eq!(reveal_doc, "local-md:Root~2FChild~2FChild.md");
let revealed = load_local_folder_page_tree_snapshot_with_reveal(
&root_uri,
Some(reveal_doc.as_str()),
)
.expect("reveal snapshot");
let items = revealed.projection["items"].as_array().expect("items");
let root_node = items
.iter()
.find(|item| item["nodeId"].as_str() == Some("local-md:Root~2FRoot.md"))
.expect("Root nested-bundle page in reveal snapshot");
assert!(
root_node["parentNodeId"].is_null()
|| root_node["parentNodeId"].as_str().map(str::is_empty).unwrap_or(false),
"Root page must remain a tree root: {root_node}"
);
let child_node = items
.iter()
.find(|item| item["nodeId"].as_str() == Some("local-md:Root~2FChild~2FChild.md"))
.expect("Child must be present after reveal");
assert_eq!(
child_node["parentNodeId"].as_str(),
Some("local-md:Root~2FRoot.md"),
"Child parent must match nested-bundle Root page id, not local-dir:Root: {child_node}"
);
let sibling_node = items
.iter()
.find(|item| item["nodeId"].as_str() == Some("local-md:Root~2FSibling~2FSibling.md"))
.expect("Sibling of revealed child must also nest under Root");
assert_eq!(
sibling_node["parentNodeId"].as_str(),
Some("local-md:Root~2FRoot.md"),
"Sibling parent must match Root page id: {sibling_node}"
);
// No orphan local-dir:Root page-group row that would fight the local-md parent.
assert!(
items.iter().all(|item| {
item["nodeId"].as_str() != Some("local-dir:Root")
&& !item["rowId"]
.as_str()
.map(|id| id.contains("page-group:Root"))
.unwrap_or(false)
}),
"reveal must not invent a local-dir/page-group Root: {items:?}"
);
// groupRowsByParent contract: only Root is a root; Child/Sibling hang under it.
let ids: std::collections::BTreeSet<String> = items
.iter()
.filter_map(|item| item["nodeId"].as_str().map(str::to_string))
.collect();
let mut roots = Vec::new();
for item in items {
let parent = item["parentNodeId"].as_str().unwrap_or("");
if parent.is_empty() || !ids.contains(parent) {
roots.push(item["nodeId"].as_str().unwrap_or("").to_string());
}
}
assert_eq!(
roots,
vec!["local-md:Root~2FRoot.md".to_string()],
"groupRowsByParent-equivalent must keep a single root after nested-bundle reveal: {roots:?}"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_file_tree_keeps_nested_bundle_filesystem_details() {
let root = temp_root("mnote-local-file-tree-nested-bundle");
+1
View File
@@ -45,6 +45,7 @@ pub(crate) mod ui_preferences;
mod vault;
mod vault_path;
mod vault_store;
mod vault_transport;
pub(crate) mod web_shell;
mod ws;
@@ -4846,7 +4846,8 @@ impl PiLabToolFacade {
.and_then(Value::as_str)
.and_then(crate::routes::vault_store::VaultItemStatus::parse)
.unwrap_or(crate::routes::vault_store::VaultItemStatus::Active);
crate::routes::vault::list_ai_vault_items(status)
// 12-2: UDS vaultd first, then in-process core (not HTTP :3000).
crate::routes::vault_transport::list_ai_vault_items(status)
}
fn vault_get(&self, params: Value) -> Result<Value, WebError> {
@@ -4855,7 +4856,7 @@ impl PiLabToolFacade {
.ok_or_else(|| {
WebError::bad_request_code("page_ai_vault_id_required", "mnote.vault.get 需要 id")
})?;
crate::routes::vault::get_ai_vault_item(&id)
crate::routes::vault_transport::get_ai_vault_item(&id)
}
fn vault_resolve(&self, params: Value) -> Result<Value, WebError> {
@@ -4870,9 +4871,13 @@ impl PiLabToolFacade {
let field = string_param(&params, "field").ok_or_else(|| {
WebError::bad_request_code(
"page_ai_vault_field_required",
"mnote.vault.resolve 需要 field=password|apikey|token",
"mnote.vault.resolve 需要 field=password|apikey|token|username|email",
)
})?;
let account_id = string_param(&params, "accountId")
.or_else(|| string_param(&params, "account_id"));
let secret_id =
string_param(&params, "secretId").or_else(|| string_param(&params, "secret_id"));
let actor = {
let id = self.context.auth.actor_id.trim();
if id.is_empty() {
@@ -4888,11 +4893,13 @@ impl PiLabToolFacade {
"密码箱 resolve 需要登录会话",
));
}
crate::routes::vault::resolve_ai_vault_secret(
crate::routes::vault_transport::resolve_ai_vault_secret(
&id,
&field,
&actor,
Some(self.context.trace.request_id.as_str()),
account_id.as_deref(),
secret_id.as_deref(),
)
}
@@ -4923,7 +4930,7 @@ impl PiLabToolFacade {
"密码箱 login 需要登录会话",
));
}
crate::routes::vault::login_ai_vault_credential(
crate::routes::vault_transport::login_ai_vault_credential(
&id,
force,
&actor,
@@ -4966,7 +4973,7 @@ impl PiLabToolFacade {
"密码箱 session 需要登录会话",
));
}
crate::routes::vault::put_ai_vault_session(
crate::routes::vault_transport::put_ai_vault_session(
&id,
&cookie,
expires.as_deref(),
File diff suppressed because it is too large Load Diff
@@ -100,4 +100,20 @@ mod tests {
assert_eq!(err.status(), StatusCode::FORBIDDEN);
assert!(deny_if_vault_sensitive_relative_path("notes/a.md").is_ok());
}
#[test]
fn denies_cipher_book_and_index_under_vault() {
assert!(is_vault_sensitive_relative_path(
".mnote/vault/cipher-book.json"
));
assert!(is_vault_sensitive_relative_path(
".mnote/vault/vault-index.json"
));
assert!(is_vault_sensitive_relative_path(
".mnote/vault/audit.jsonl"
));
let err = deny_if_vault_sensitive_relative_path(".mnote/vault/cipher-book.json")
.expect_err("must deny cipher-book via general file surface");
assert_eq!(err.code(), "vault_path_denied");
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,391 @@
//! Pi / agent vault transport: prefer vaultd UDS, fall back to in-process core.
//!
//! Aligns with 12-2 §5.4 / §6.4 — same sock→core policy as `mnote-vault` CLI.
//! Does **not** depend on HTTP to :3000 for list/get/resolve/login/session data plane.
//!
//! Env:
//! - `MNOTE_VAULT_PI_TRANSPORT=auto|uds|local` (default `auto`)
//! - `MNOTE_VAULT_SOCK` / token env handled by `mnote-vault-core::token`
use crate::error::WebError;
use crate::routes::vault;
use crate::routes::vault_store::VaultItemStatus;
use mnote_vault_core::default_sock_path;
use mnote_vault_core::read_token_from_env_or_file;
use serde_json::{json, Value};
use std::io::{Read, Write};
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TransportMode {
/// UDS if reachable, else local core.
Auto,
/// UDS only (fail if sock down).
UdsOnly,
/// In-process core only (skip sock).
LocalOnly,
}
fn transport_mode() -> TransportMode {
match std::env::var("MNOTE_VAULT_PI_TRANSPORT")
.ok()
.as_deref()
.map(str::trim)
.map(|s| s.to_ascii_lowercase())
.as_deref()
{
Some("uds") | Some("remote") | Some("sock") => TransportMode::UdsOnly,
Some("local") | Some("core") | Some("embedded") => TransportMode::LocalOnly,
_ => TransportMode::Auto,
}
}
fn sock_reachable(path: &Path) -> bool {
#[cfg(unix)]
{
if !path.exists() {
return false;
}
std::os::unix::net::UnixStream::connect(path).is_ok()
}
#[cfg(not(unix))]
{
let _ = path;
false
}
}
#[cfg(unix)]
fn uds_http(
sock: &Path,
method: &str,
path_and_query: &str,
body: Option<&str>,
bearer: Option<&str>,
) -> Result<(u16, String), WebError> {
use std::os::unix::net::UnixStream;
let mut stream = UnixStream::connect(sock).map_err(|e| {
WebError::service_unavailable_code(
"vaultd_unavailable",
format!("无法连接 vaultd sock {}: {e}", sock.display()),
)
})?;
let body_bytes = body.unwrap_or("").as_bytes();
let mut req = format!(
"{method} {path_and_query} HTTP/1.1\r\nHost: mnote-vaultd\r\nConnection: close\r\n"
);
if let Some(token) = bearer {
req.push_str(&format!("Authorization: Bearer {token}\r\n"));
}
if body.is_some() {
req.push_str("Content-Type: application/json\r\n");
req.push_str(&format!("Content-Length: {}\r\n", body_bytes.len()));
} else {
req.push_str("Content-Length: 0\r\n");
}
req.push_str("\r\n");
stream
.write_all(req.as_bytes())
.and_then(|_| {
if !body_bytes.is_empty() {
stream.write_all(body_bytes)
} else {
Ok(())
}
})
.map_err(|e| {
WebError::service_unavailable_code(
"vaultd_unavailable",
format!("写 sock 失败: {e}"),
)
})?;
let mut raw = Vec::new();
stream.read_to_end(&mut raw).map_err(|e| {
WebError::service_unavailable_code(
"vaultd_unavailable",
format!("读 sock 失败: {e}"),
)
})?;
let text = String::from_utf8_lossy(&raw);
parse_http_response(&text)
}
#[cfg(not(unix))]
fn uds_http(
_sock: &Path,
_method: &str,
_path_and_query: &str,
_body: Option<&str>,
_bearer: Option<&str>,
) -> Result<(u16, String), WebError> {
Err(WebError::service_unavailable_code(
"vaultd_unavailable",
"UDS 仅支持 Unix",
))
}
fn parse_http_response(text: &str) -> Result<(u16, String), WebError> {
let (head, body) = text
.split_once("\r\n\r\n")
.or_else(|| text.split_once("\n\n"))
.unwrap_or((text, ""));
let status_line = head.lines().next().unwrap_or("");
let status: u16 = status_line
.split_whitespace()
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(500);
Ok((status, body.to_string()))
}
fn map_http_error(status: u16, body: &str) -> WebError {
if let Ok(v) = serde_json::from_str::<Value>(body) {
let code = v
.get("code")
.and_then(Value::as_str)
.unwrap_or("vaultd_error");
let message = v
.get("message")
.and_then(Value::as_str)
.unwrap_or(body)
.to_string();
let http_status = axum::http::StatusCode::from_u16(status)
.unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
// Prefer stable vault_* codes when present.
let code_static: &'static str = match code {
"vault_token_missing" => "vault_token_missing",
"vault_token_invalid" => "vault_token_invalid",
"vault_token_expired" => "vault_token_expired",
"vault_scope_denied" => "vault_scope_denied",
"vault_actor_mismatch" => "vault_actor_mismatch",
"vault_item_not_found" => "vault_item_not_found",
"vault_resolve_field_invalid" => "vault_resolve_field_invalid",
"vault_resolve_inactive" => "vault_resolve_inactive",
"vaultd_unavailable" => "vaultd_unavailable",
"bad_request" => "bad_request",
"vault_session_inactive" => "vault_session_inactive",
"vault_login_no_url" => "vault_login_no_url",
"vault_login_human_required" => "vault_login_human_required",
_ if code.starts_with("vault_") => "vault_error",
_ => "vaultd_error",
};
let mut err = WebError::new(http_status, code_static, message);
if code_static == "vault_error" {
err = err.with_details(json!({ "upstreamCode": code }));
}
return err;
}
WebError::new(
axum::http::StatusCode::from_u16(status)
.unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR),
"vaultd_error",
format!("HTTP {status}: {body}"),
)
}
fn client_token() -> Result<Option<String>, WebError> {
match read_token_from_env_or_file() {
Ok(t) => Ok(Some(t)),
Err(_) => Ok(None),
}
}
fn with_transport<F, G>(via_uds: F, via_local: G) -> Result<Value, WebError>
where
F: FnOnce(Option<&str>) -> Result<Value, WebError>,
G: FnOnce() -> Result<Value, WebError>,
{
let mode = transport_mode();
if mode == TransportMode::LocalOnly {
return via_local();
}
let sock = default_sock_path();
if sock_reachable(&sock) {
let token = client_token()?;
match via_uds(token.as_deref()) {
Ok(v) => return Ok(v),
Err(e) if mode == TransportMode::UdsOnly => return Err(e),
Err(_) => {
// Soft fallback to in-process core (same as CLI uds_fallback_local).
}
}
} else if mode == TransportMode::UdsOnly {
return Err(WebError::service_unavailable_code(
"vaultd_unavailable",
format!("vaultd sock 不可达: {}", sock.display()),
));
}
via_local()
}
fn core_status(status: VaultItemStatus) -> VaultItemStatus {
status
}
/// List AI vault (Pi tool). UDS → core.
pub fn list_ai_vault_items(status: VaultItemStatus) -> Result<Value, WebError> {
let status_q = status.as_str();
with_transport(
|token| {
let path = format!("/v1/items?status={status_q}");
let (st, body) = uds_http(&default_sock_path(), "GET", &path, None, token)?;
if st >= 400 {
return Err(map_http_error(st, &body));
}
serde_json::from_str(&body).map_err(|e| {
WebError::internal(format!("vaultd list JSON 无效: {e}"))
})
},
|| vault::list_ai_vault_items(core_status(status)),
)
}
pub fn get_ai_vault_item(id: &str) -> Result<Value, WebError> {
with_transport(
|token| {
let path = format!("/v1/items/{id}");
let (st, body) = uds_http(&default_sock_path(), "GET", &path, None, token)?;
if st >= 400 {
return Err(map_http_error(st, &body));
}
serde_json::from_str(&body).map_err(|e| {
WebError::internal(format!("vaultd get JSON 无效: {e}"))
})
},
|| vault::get_ai_vault_item(id),
)
}
pub fn resolve_ai_vault_secret(
id: &str,
field: &str,
actor: &str,
request_id: Option<&str>,
account_id: Option<&str>,
secret_id: Option<&str>,
) -> Result<Value, WebError> {
let id_owned = id.to_string();
let field_owned = field.to_string();
let account = account_id.map(str::to_string);
let secret = secret_id.map(str::to_string);
with_transport(
|token| {
let path = format!("/v1/items/{id_owned}/resolve");
let body = json!({
"field": field_owned,
"accountId": account,
"secretId": secret,
});
let body_s = body.to_string();
let (st, resp) =
uds_http(&default_sock_path(), "POST", &path, Some(&body_s), token)?;
if st >= 400 {
return Err(map_http_error(st, &resp));
}
serde_json::from_str(&resp).map_err(|e| {
WebError::internal(format!("vaultd resolve JSON 无效: {e}"))
})
},
|| {
vault::resolve_ai_vault_secret(
&id_owned,
&field_owned,
actor,
request_id,
account.as_deref(),
secret.as_deref(),
)
},
)
}
pub fn login_ai_vault_credential(
id: &str,
force_refresh: bool,
actor: &str,
request_id: Option<&str>,
) -> Result<Value, WebError> {
let id_owned = id.to_string();
with_transport(
|token| {
let path = format!("/v1/items/{id_owned}/login");
let body = json!({ "forceRefresh": force_refresh });
let body_s = body.to_string();
let (st, resp) =
uds_http(&default_sock_path(), "POST", &path, Some(&body_s), token)?;
if st >= 400 {
return Err(map_http_error(st, &resp));
}
serde_json::from_str(&resp).map_err(|e| {
WebError::internal(format!("vaultd login JSON 无效: {e}"))
})
},
|| vault::login_ai_vault_credential(&id_owned, force_refresh, actor, request_id),
)
}
pub fn put_ai_vault_session(
id: &str,
cookie_header: &str,
expires_at: Option<&str>,
source: &str,
actor: &str,
request_id: Option<&str>,
) -> Result<Value, WebError> {
let id_owned = id.to_string();
let cookie = cookie_header.to_string();
let expires = expires_at.map(str::to_string);
let source_owned = source.to_string();
with_transport(
|token| {
let path = format!("/v1/items/{id_owned}/session");
let body = json!({
"cookieHeader": cookie,
"expiresAt": expires,
"source": source_owned,
});
let body_s = body.to_string();
let (st, resp) =
uds_http(&default_sock_path(), "POST", &path, Some(&body_s), token)?;
if st >= 400 {
return Err(map_http_error(st, &resp));
}
serde_json::from_str(&resp).map_err(|e| {
WebError::internal(format!("vaultd session JSON 无效: {e}"))
})
},
|| {
vault::put_ai_vault_session(
&id_owned,
&cookie,
expires.as_deref(),
&source_owned,
actor,
request_id,
)
},
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn transport_mode_defaults_to_auto() {
// Do not assert env-free default in parallel tests; just ensure parser is stable.
let _ = transport_mode();
assert!(matches!(
TransportMode::Auto,
TransportMode::Auto
));
}
#[test]
fn list_local_only_works_without_sock() {
std::env::set_var("MNOTE_VAULT_PI_TRANSPORT", "local");
// May fail if AI vault workspace missing in CI sandbox — only check no panic on mode.
let _ = list_ai_vault_items(VaultItemStatus::Active);
std::env::remove_var("MNOTE_VAULT_PI_TRANSPORT");
}
}
@@ -320,6 +320,46 @@
box-shadow: inset 0 0 0 1px rgba(0, 110, 40, 0.28);
}
/* Wolai-style sibling reorder lines (before / after). Nest-into uses full-row fill above. */
.sidebar-tree .tree-row {
position: relative;
}
.sidebar-tree .tree-row[data-drop-position="before"][data-drop-feedback="true"],
.sidebar-tree .tree-row[data-drop-position="before"][data-drop-target="true"],
.sidebar-tree .tree-row[data-drop-position="after"][data-drop-feedback="true"],
.sidebar-tree .tree-row[data-drop-position="after"][data-drop-target="true"] {
background: transparent;
box-shadow: none;
}
.sidebar-tree .tree-row[data-drop-position="inside"][data-drop-feedback="true"],
.sidebar-tree .tree-row[data-drop-position="inside"][data-drop-target="true"] {
background: rgba(0, 110, 40, 0.12);
box-shadow: inset 0 0 0 1px rgba(0, 110, 40, 0.28);
}
.sidebar-tree .tree-row[data-drop-position="before"]::before,
.sidebar-tree .tree-row[data-drop-position="after"]::after {
content: "";
position: absolute;
left: 20px;
right: 8px;
height: 2px;
border-radius: 999px;
background: rgba(0, 110, 40, 0.85);
pointer-events: none;
z-index: 2;
}
.sidebar-tree .tree-row[data-drop-position="before"]::before {
top: 0;
}
.sidebar-tree .tree-row[data-drop-position="after"]::after {
bottom: 0;
}
.sidebar-tree .tree-toggle,
.sidebar-tree .tree-spacer {
width: 20px;
@@ -90,6 +90,64 @@
background: #f7f7f6;
}
/* 顶栏(密文簿旁):插入已有密文 [Key];编辑态显示,滚动详情时仍可见 */
.mnote-vault-header-actions .mnote-vault-insert-cipher,
.mnote-vault-insert-cipher {
display: inline-flex;
align-items: center;
margin: 0;
}
.mnote-vault-insert-cipher[hidden] {
display: none !important;
}
.mnote-vault-insert-cipher select {
height: 30px;
min-width: 118px;
max-width: 180px;
padding: 0 8px;
border: 1px solid rgba(27, 28, 28, 0.12);
border-radius: 4px;
background: #ffffff;
color: #37352f;
font: inherit;
font-size: 12px;
line-height: 28px;
cursor: pointer;
}
.mnote-vault-insert-cipher select:hover {
background: #f7f7f6;
}
.mnote-vault-form-hint {
margin: 0 0 8px;
padding: 0 2px;
color: #8b8782;
font-size: 12px;
line-height: 18px;
}
.mnote-vault-form-hint code {
font-size: 11px;
padding: 0 3px;
border-radius: 3px;
background: rgba(27, 28, 28, 0.05);
}
.mnote-vault-sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.mnote-vault-detail-actions button.is-danger {
color: #c93a32;
border-color: rgba(201, 58, 50, 0.28);
@@ -732,3 +790,296 @@
grid-template-columns: 1fr;
}
}
/* Multi-account / multi-secret collapsible groups */
.mnote-vault-section {
display: flex;
flex-direction: column;
gap: 8px;
margin: 4px 0 12px;
padding: 10px 12px;
border: 1px solid rgba(27, 28, 28, 0.08);
border-radius: 8px;
background: rgba(247, 247, 246, 0.55);
}
.mnote-vault-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 12px;
font-weight: 600;
color: #37352f;
}
.mnote-vault-section-head > button {
height: 26px;
padding: 0 10px;
border: 1px solid rgba(27, 28, 28, 0.12);
border-radius: 4px;
background: #fff;
font: inherit;
font-size: 12px;
cursor: pointer;
}
.mnote-vault-section-head > button:hover {
background: #f7f7f6;
}
/* Multi-URL (equivalent site endpoints / fallbacks) */
.mnote-vault-url-hint {
margin: 0 0 4px;
font-size: 12px;
color: rgba(55, 53, 47, 0.55);
}
.mnote-vault-url-row {
display: grid;
grid-template-columns: 56px minmax(0, 1fr) 28px;
align-items: center;
gap: 8px;
}
.mnote-vault-url-label {
font-size: 12px;
color: rgba(55, 53, 47, 0.65);
white-space: nowrap;
}
.mnote-vault-url-row input[type="url"] {
width: 100%;
min-width: 0;
height: 32px;
padding: 0 10px;
border: 1px solid rgba(27, 28, 28, 0.12);
border-radius: 4px;
font: inherit;
font-size: 13px;
background: #fff;
}
.mnote-vault-url-row input[type="url"]:focus {
outline: 2px solid rgba(35, 131, 226, 0.35);
border-color: rgba(35, 131, 226, 0.55);
}
.mnote-vault-url-row > button {
height: 28px;
width: 28px;
padding: 0;
border: 1px solid rgba(27, 28, 28, 0.1);
border-radius: 4px;
background: #fff;
font: inherit;
cursor: pointer;
color: #9b2c2c;
}
.mnote-vault-url-row > button:hover {
background: #fdf2f2;
}
.mnote-vault-url-spacer {
display: block;
width: 28px;
height: 28px;
}
.mnote-vault-urls-view .mnote-vault-field-value a {
color: #2383e2;
word-break: break-all;
}
.mnote-vault-slot-group {
border: 1px solid rgba(27, 28, 28, 0.1);
border-radius: 6px;
background: #fff;
overflow: hidden;
}
.mnote-vault-slot-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
cursor: pointer;
list-style: none;
user-select: none;
}
.mnote-vault-slot-summary::-webkit-details-marker {
display: none;
}
.mnote-vault-slot-title {
font-size: 12px;
font-weight: 600;
color: #37352f;
}
.mnote-vault-slot-actions {
display: inline-flex;
gap: 6px;
}
.mnote-vault-slot-actions button {
height: 24px;
padding: 0 8px;
border: 1px solid rgba(27, 28, 28, 0.12);
border-radius: 4px;
background: #fff;
font: inherit;
font-size: 11px;
cursor: pointer;
}
.mnote-vault-slot-actions button.is-danger {
color: #c93a32;
border-color: rgba(201, 58, 50, 0.28);
}
.mnote-vault-slot-body {
display: flex;
flex-direction: column;
gap: 6px;
padding: 0 10px 10px;
border-top: 1px solid rgba(27, 28, 28, 0.06);
}
.mnote-vault-slot-empty {
font-size: 12px;
padding: 4px 2px 2px;
}
/* Nested appendix secrets under each account (default collapsed) */
.mnote-vault-account-secrets {
margin-top: 8px;
padding: 8px 8px 6px;
border: 1px dashed rgba(27, 28, 28, 0.12);
border-radius: 6px;
background: #fafaf9;
display: flex;
flex-direction: column;
gap: 8px;
}
.mnote-vault-account-secrets.is-collapsed {
gap: 0;
padding-bottom: 6px;
}
.mnote-vault-account-secrets-body {
display: flex;
flex-direction: column;
gap: 8px;
}
.mnote-vault-account-secrets-body[hidden] {
display: none !important;
}
.mnote-vault-account-secrets-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 11px;
font-weight: 600;
color: #6d6a65;
letter-spacing: 0.02em;
}
.mnote-vault-secrets-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 0;
padding: 2px 4px;
border: none;
background: transparent;
font: inherit;
font-size: 11px;
font-weight: 600;
color: #6d6a65;
cursor: pointer;
text-align: left;
}
.mnote-vault-secrets-toggle:hover {
color: #37352f;
}
.mnote-vault-secrets-chevron {
display: inline-block;
width: 1em;
font-size: 9px;
line-height: 1;
color: #9b9a97;
}
.mnote-vault-secrets-count {
font-weight: 500;
color: #9b9a97;
}
.mnote-vault-account-secrets-head button:not(.mnote-vault-secrets-toggle) {
height: 24px;
padding: 0 8px;
border: 1px solid rgba(27, 28, 28, 0.12);
border-radius: 4px;
background: #fff;
font: inherit;
font-size: 11px;
cursor: pointer;
}
.mnote-vault-account-secrets-empty {
font-size: 12px;
padding: 2px 0;
}
.mnote-vault-nested-secret {
border: 1px solid rgba(27, 28, 28, 0.08);
border-radius: 5px;
background: #fff;
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.mnote-vault-nested-secret-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.mnote-vault-nested-secret-title {
font-size: 12px;
font-weight: 600;
color: #37352f;
}
.mnote-vault-nested-secret-head button.is-danger {
height: 24px;
padding: 0 8px;
border: 1px solid rgba(201, 58, 50, 0.28);
border-radius: 4px;
background: #fff;
color: #c93a32;
font: inherit;
font-size: 11px;
cursor: pointer;
}
/* Cipher book plain input (visible while typing) */
.mnote-vault-cipher-add input.mnote-vault-secret-input-plain,
.mnote-vault-cipher-add input[type="text"] {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}