Support live recursive folder shares and follow subject moves

This commit is contained in:
eezd committed 2026-10-20 17:20:23 +08:00
1 parent 89472fe6fd
commit 84e89c8fe4
17 files changed
+620 -127

No files matched your search

+3 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import Icon from './Icon.vue'
import { noteTitle } from './paths'
import { basename } from './paths'
import SharePanel from './SharePanel.vue'
defineProps<{ path: string }>()
@@ -9,9 +9,9 @@ const emit = defineEmits<{ close: [] }>()
<template>
<div class="rm-dialog-backdrop" @click.self="emit('close')">
<div class="rm-dialog rm-share-dialog" role="dialog" :aria-label="`分享“${noteTitle(path)}”`">
<div class="rm-dialog rm-share-dialog" role="dialog" :aria-label="`分享“${basename(path)}”`">
<div class="rm-dialog-head">
<h2>分享“{{ noteTitle(path) }}”</h2>
<h2>分享“{{ basename(path) }}”</h2>
<button class="rm-icon-btn" title="关闭" @click="emit('close')"><Icon name="close" /></button>
</div>
<SharePanel :path="path" />
+5 -5
View File
@@ -5,7 +5,7 @@ import { api, ApiError, type Share } from './api'
import Icon from './Icon.vue'
import { toast } from './workspace'
// path 为空时列出全部分享(管理列表),否则管理这一篇笔记的分享
// path 为空时列出全部分享,否则管理此文件或文件夹的分享。
const props = defineProps<{ path: string | null }>()
const EXPIRY_OPTIONS = [
@@ -77,21 +77,21 @@ onMounted(load)
</select>
<button class="rm-btn rm-btn-primary" type="submit" :disabled="busy">创建链接</button>
</form>
<p class="rm-share-note">分享页只读,显示笔记的当前内容及其 .assets 中的图片;拿到链接的人都能访问。</p>
<p class="rm-share-note">分享只读并显示最新内容。分享文件夹会公开其中所有文件和子目录,包括以后新增或移入的内容;移出的内容不再公开。分享主体移动后链接保持有效。</p>
<p v-if="failed" class="rm-tree-empty">读取分享失败</p>
<p v-else-if="shares && !shares.length" class="rm-tree-empty">{{ path ? '还没有分享链接' : '还没有分享任何笔记' }}</p>
<p v-else-if="shares && !shares.length" class="rm-tree-empty">{{ path ? '还没有分享链接' : '还没有分享任何文件或文件夹' }}</p>
<ul v-else class="rm-trash-list">
<li v-for="s in shares ?? []" :key="s.token">
<Icon name="link" />
<div class="rm-trash-info">
<span v-if="!path" class="rm-trash-path">{{ s.path.replace(/\.md$/, '') }}</span>
<span v-if="!path" class="rm-trash-path">{{ s.path }}</span>
<span class="rm-share-link" :class="{ 'rm-trash-path': path }">{{ linkOf(s) }}</span>
<span class="rm-trash-time">
{{ fmt.format(s.createdAt) }} 创建
<template v-if="s.hasPassword"> · 有密码</template>
<template v-if="expired(s)"> · <span class="rm-share-bad">已过期</span></template>
<template v-else-if="s.expiresAt"> · {{ fmt.format(s.expiresAt) }} 到期</template>
<template v-if="!s.exists"> · <span class="rm-share-bad">笔记不存在</span></template>
<template v-if="!s.exists"> · <span class="rm-share-bad">文件或文件夹不存在</span></template>
</span>
</div>
<button class="rm-btn" @click="copy(s)">复制链接</button>
+1 -1
View File
@@ -150,7 +150,7 @@ function onDrop(e: DragEvent) {
</template>
<button @click="runMenu('rename')"><Icon name="edit" />重命名</button>
<button @click="runMenu('move')"><Icon name="folder" />移动到…</button>
<button v-if="!node.dir" @click="runMenu('share')"><Icon name="link" />分享</button>
<button @click="runMenu('share')"><Icon name="link" />分享</button>
<button class="danger" @click="runMenu('delete')"><Icon name="trash" />删除</button>
</div>
<div v-if="menuOpen" class="rm-menu-backdrop" @click.stop="menuOpen = false" @contextmenu.prevent.stop="menuOpen = false" />
+1 -1
View File
@@ -43,7 +43,7 @@ export interface Share {
// 0 为永久
expiresAt: number
hasPassword: boolean
// 笔记是否仍存在
// 分享的文件或文件夹是否仍存在
exists: boolean
}
+22
View File
@@ -20,6 +20,28 @@ afterEach(() => {
})
describe('事件同步', () => {
it('目录移动映射整个已打开的子树,并把未保存输入写入新路径', async () => {
const tree = vi.spyOn(api, 'tree').mockResolvedValue([{ path: 'from/note.md', dir: false, size: 0, mtime: 0 }])
vi.spyOn(api, 'settings').mockResolvedValue({})
vi.spyOn(api, 'plugins').mockResolvedValue([])
vi.spyOn(drafts, 'all').mockResolvedValue([])
vi.spyOn(drafts, 'put').mockImplementation(async (draft) => draft.path)
const saved = new Map<string, string>()
vi.spyOn(api, 'writeFile').mockImplementation(async (path, content) => { saved.set(path, content); return 'r2' })
await startWorkspace()
const v = await edit('from/note.md')
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: '尚未保存的输入' } })
tree.mockResolvedValue([{ path: 'to/note.md', dir: false, size: 0, mtime: 0 }])
vi.mocked(connectEvents).mock.lastCall![0].onEvents([{ type: 'moved', from: 'from', path: 'to' }, { type: 'tree' }])
await vi.waitFor(() => expect(ws.active).toBe('to/note.md'))
expect(editorStateOf('from/note.md')).toBeUndefined()
expect(editorStateOf('to/note.md')!.doc.toString()).toBe('尚未保存的输入')
await flush('to/note.md')
expect(saved.get('to/note.md')).toBe('尚未保存的输入')
expect(saved.has('from/note.md')).toBe(false)
await closeTab('to/note.md')
})
it('正文同步后才通知插件,同步失败时不广播成功且不阻塞后续批次', async () => {
const entries = [{ path: 'sync.md', dir: false, size: 0, mtime: 0 }]
vi.spyOn(api, 'tree').mockResolvedValue(entries)
+1 -1
View File
@@ -783,7 +783,7 @@ async function handleEvents(evs: VaultEvent[]) {
// 自己保存产生的事件 rev 与本地一致;有本地修改时不覆盖,交由下次保存的冲突检测处理
if (tab && tab.rev !== ev.rev) await syncOpenTab(tab)
} else if (ev.type === 'moved') {
if (tabOf(ev.from!) && !tabOf(ev.path!)) remapOpenNotes(ev.from!, ev.path!)
if (affected(ev.from!).length && !affected(ev.path!).length) remapOpenNotes(ev.from!, ev.path!)
remapRecent(ev.from!, ev.path!)
} else if (ev.type === 'deleted') {
const tab = tabOf(ev.path!)