Add quick switcher and fix directory refresh races
- Ctrl+P quick switcher with fuzzy matching on note titles and folders; empty input lists recent notes with the previous note first; creates the note (and folders) when no note has that name; also reachable from the mobile top bar - Track recently opened notes, following renames and moves - Serialize directory refreshes and discard responses that raced with local edits; a stale response could drop an optimistically added note, and debounced callers could wait forever, leaving the workspace startup unfinished - Wait for a pending note creation before renaming it - Focus the editor after opening a note from the switcher or search
This commit is contained in:
1 parent
55f0ee174e
commit
d096f0da70
6 files changed
+390
-20
No files matched your search
@@ -0,0 +1,166 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { fuzzyMatch } from './fuzzy'
|
||||
import Icon from './Icon.vue'
|
||||
import { dirname, NOTE_EXT, noteTitle } from './paths'
|
||||
import { createNoteAt, openAt, ws } from './workspace'
|
||||
|
||||
const MAX_ITEMS = 50
|
||||
|
||||
interface Item {
|
||||
path: string
|
||||
title: string
|
||||
dir: string
|
||||
// 命中字符在标题、目录中的下标
|
||||
titleHits: Set<number>
|
||||
dirHits: Set<number>
|
||||
}
|
||||
|
||||
const input = ref<HTMLInputElement>()
|
||||
const list = ref<HTMLElement>()
|
||||
const query = ref('')
|
||||
const selected = ref(0)
|
||||
|
||||
// 取消时把焦点还给打开前的元素(通常是编辑器)
|
||||
const previousFocus = document.activeElement as HTMLElement | null
|
||||
function close() {
|
||||
ws.switcherOpen = false
|
||||
}
|
||||
function cancel() {
|
||||
close()
|
||||
previousFocus?.focus()
|
||||
}
|
||||
|
||||
/** 按命中与否把文本切成连续片段 */
|
||||
function segments(text: string, hits: Set<number>) {
|
||||
const out: { text: string; hit: boolean }[] = []
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const hit = hits.has(i)
|
||||
const last = out[out.length - 1]
|
||||
if (last?.hit === hit) last.text += text[i]
|
||||
else out.push({ text: text[i], hit })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const items = computed<Item[]>(() => {
|
||||
const q = query.value.trim()
|
||||
const recentRank = new Map(ws.recent.map((p, i) => [p, i]))
|
||||
const notes = ws.entries.filter((e) => !e.dir)
|
||||
const toItem = (path: string, positions: number[] = []): Item => {
|
||||
const dir = dirname(path)
|
||||
const offset = dir ? dir.length + 1 : 0
|
||||
return {
|
||||
path,
|
||||
title: noteTitle(path),
|
||||
dir,
|
||||
titleHits: new Set(positions.filter((p) => p >= offset).map((p) => p - offset)),
|
||||
dirHits: new Set(positions.filter((p) => p < offset)),
|
||||
}
|
||||
}
|
||||
if (!q) {
|
||||
// 最近打开的在前(当前笔记除外,回车即回到上一篇),其余按修改时间
|
||||
const rank = (p: string) => recentRank.get(p) ?? Infinity
|
||||
const mtime = new Map(notes.map((e) => [e.path, e.mtime]))
|
||||
return notes
|
||||
.map((e) => e.path)
|
||||
.filter((p) => p !== ws.active)
|
||||
.sort((a, b) => rank(a) - rank(b) || mtime.get(b)! - mtime.get(a)!)
|
||||
.slice(0, MAX_ITEMS)
|
||||
.map((p) => toItem(p))
|
||||
}
|
||||
const scored: { path: string; score: number; positions: number[] }[] = []
|
||||
for (const e of notes) {
|
||||
const target = e.path.slice(0, -NOTE_EXT.length)
|
||||
const m = fuzzyMatch(q, target, target.lastIndexOf('/') + 1)
|
||||
if (m) scored.push({ path: e.path, ...m })
|
||||
}
|
||||
scored.sort(
|
||||
(a, b) => b.score - a.score || (recentRank.get(a.path) ?? Infinity) - (recentRank.get(b.path) ?? Infinity) || a.path.length - b.path.length,
|
||||
)
|
||||
return scored.slice(0, MAX_ITEMS).map((s) => toItem(s.path, s.positions))
|
||||
})
|
||||
|
||||
// 没有同名笔记时提供"新建";输入中的 / 表示目录
|
||||
const createPath = computed(() => {
|
||||
const q = query.value.trim().replace(/\.md$/i, '')
|
||||
if (!q || !q.split('/').every((seg) => seg.trim() && !seg.startsWith('.'))) return null
|
||||
const path = q + NOTE_EXT
|
||||
return ws.entries.some((e) => e.path.toLowerCase() === path.toLowerCase()) ? null : path
|
||||
})
|
||||
const count = computed(() => items.value.length + (createPath.value ? 1 : 0))
|
||||
|
||||
watch(query, () => (selected.value = 0))
|
||||
watch(selected, (i) => list.value?.children[i]?.scrollIntoView({ block: 'nearest' }))
|
||||
|
||||
function choose(i: number) {
|
||||
const item = items.value[i]
|
||||
const create = createPath.value
|
||||
close()
|
||||
if (item) void openAt(item.path)
|
||||
else if (create) {
|
||||
void createNoteAt(create).then((ok) => {
|
||||
if (ok) void openAt(create)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') cancel()
|
||||
else if (e.key === 'ArrowDown' && count.value) selected.value = (selected.value + 1) % count.value
|
||||
else if (e.key === 'ArrowUp' && count.value) selected.value = (selected.value - 1 + count.value) % count.value
|
||||
else if (e.key === 'Enter' && !e.isComposing && count.value) choose(selected.value)
|
||||
else return
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
onMounted(() => input.value?.focus())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rm-switcher-backdrop" @mousedown.self="cancel">
|
||||
<div class="rm-switcher" role="dialog" aria-label="快速打开">
|
||||
<div class="rm-search-box">
|
||||
<Icon name="search" />
|
||||
<input
|
||||
ref="input"
|
||||
v-model="query"
|
||||
class="rm-search-input"
|
||||
placeholder="输入笔记名称,回车打开或新建"
|
||||
enterkeyhint="go"
|
||||
@keydown="onKeydown"
|
||||
@blur="close"
|
||||
/>
|
||||
</div>
|
||||
<ul v-if="count" ref="list" class="rm-switcher-list" @mousedown.prevent>
|
||||
<li
|
||||
v-for="(item, i) in items"
|
||||
:key="item.path"
|
||||
class="rm-switcher-item"
|
||||
:class="{ active: i === selected }"
|
||||
@click="choose(i)"
|
||||
@mousemove="selected = i"
|
||||
>
|
||||
<Icon name="note" />
|
||||
<span class="rm-switcher-title">
|
||||
<span v-for="(s, j) in segments(item.title, item.titleHits)" :key="j" :class="{ 'rm-search-hit': s.hit }">{{ s.text }}</span>
|
||||
</span>
|
||||
<span v-if="item.dir" class="rm-switcher-dir">
|
||||
<span v-for="(s, j) in segments(item.dir, item.dirHits)" :key="j" :class="{ 'rm-search-hit': s.hit }">{{ s.text }}</span>
|
||||
</span>
|
||||
</li>
|
||||
<li
|
||||
v-if="createPath"
|
||||
class="rm-switcher-item"
|
||||
:class="{ active: selected === items.length }"
|
||||
@click="choose(items.length)"
|
||||
@mousemove="selected = items.length"
|
||||
>
|
||||
<Icon name="noteAdd" />
|
||||
<span class="rm-switcher-title">新建笔记“{{ createPath.slice(0, -NOTE_EXT.length) }}”</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="rm-tree-empty">还没有笔记</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -4,6 +4,7 @@ import { api } from './api'
|
||||
import EditorPane from './EditorPane.vue'
|
||||
import Icon from './Icon.vue'
|
||||
import NoteBanner from './NoteBanner.vue'
|
||||
import QuickSwitcher from './QuickSwitcher.vue'
|
||||
import { noteTitle } from './paths'
|
||||
import Sidebar from './Sidebar.vue'
|
||||
import TabBar from './TabBar.vue'
|
||||
@@ -50,10 +51,12 @@ onBeforeUnmount(stopWorkspace)
|
||||
<button class="rm-icon-btn" title="目录" @click="ws.sidebarOpen = true"><Icon name="menu" :size="20" /></button>
|
||||
<span class="rm-mobile-title">{{ title }}</span>
|
||||
<span class="rm-save-state" :class="{ on: saving }" />
|
||||
<button class="rm-icon-btn" title="快速打开" @click="ws.switcherOpen = true"><Icon name="search" :size="20" /></button>
|
||||
</header>
|
||||
<TabBar />
|
||||
<NoteBanner />
|
||||
<EditorPane />
|
||||
</main>
|
||||
<QuickSwitcher v-if="ws.switcherOpen" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -199,6 +199,24 @@ button {
|
||||
word-break: break-all;
|
||||
}
|
||||
.rm-search-hit { background: rgba(66, 184, 131, 0.28); color: var(--rm-text); border-radius: 2px; }
|
||||
/* ---- 快速切换 ---- */
|
||||
.rm-switcher-backdrop { position: fixed; inset: 0; z-index: 50; display: flex; justify-content: center; align-items: flex-start; padding-top: 12vh; background: rgba(0, 0, 0, 0.18); }
|
||||
.rm-switcher {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(560px, calc(100vw - 24px));
|
||||
max-height: 60vh;
|
||||
padding-top: 10px;
|
||||
border-radius: 10px;
|
||||
background: var(--rm-bg);
|
||||
box-shadow: var(--rm-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
.rm-switcher-list { flex: 1; overflow: auto; list-style: none; margin: 0; padding: 0 6px 6px; }
|
||||
.rm-switcher-item { display: flex; align-items: center; gap: 8px; height: 34px; padding: 0 10px; border-radius: 6px; font-size: 14px; color: var(--rm-text-muted); cursor: pointer; }
|
||||
.rm-switcher-item.active { background: var(--rm-active); }
|
||||
.rm-switcher-title { color: var(--rm-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.rm-switcher-dir { flex: 1; min-width: 0; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: right; }
|
||||
.rm-sidebar-foot { display: flex; gap: 4px; padding: 8px; border-top: 1px solid var(--rm-divider); }
|
||||
.rm-foot-btn {
|
||||
flex: 1;
|
||||
@@ -392,5 +410,9 @@ button {
|
||||
/* 小于 16px 时 iOS Safari 聚焦输入框会自动放大页面 */
|
||||
.rm-search-input { height: 40px; font-size: 16px; }
|
||||
.rm-search-item { padding: 10px 8px; }
|
||||
/* 手机上从顶部弹出,给软键盘留出空间 */
|
||||
.rm-switcher-backdrop { padding-top: calc(8px + env(safe-area-inset-top)); }
|
||||
.rm-switcher { max-height: 50vh; }
|
||||
.rm-switcher-item { height: 44px; font-size: 15px; }
|
||||
.rm-trash-list li { flex-wrap: wrap; }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { fuzzyMatch } from './fuzzy'
|
||||
|
||||
const match = (q: string, path: string) => fuzzyMatch(q, path, path.lastIndexOf('/') + 1)
|
||||
const rank = (q: string, paths: string[]) =>
|
||||
paths
|
||||
.map((p) => ({ p, m: match(q, p) }))
|
||||
.filter((x) => x.m)
|
||||
.sort((a, b) => b.m!.score - a.m!.score)
|
||||
.map((x) => x.p)
|
||||
|
||||
describe('fuzzyMatch', () => {
|
||||
it('不区分大小写,记录命中位置', () => {
|
||||
expect(match('READ', 'docs/readme')?.positions).toEqual([5, 6, 7, 8])
|
||||
})
|
||||
|
||||
it('按顺序零散命中,顺序不对则不匹配', () => {
|
||||
expect(match('dpl', 'deploy')?.positions).toEqual([0, 2, 3])
|
||||
expect(match('lpd', 'deploy')).toBeNull()
|
||||
})
|
||||
|
||||
it('多个词都要命中,可分别落在目录和标题', () => {
|
||||
expect(match('项目 部署', '项目/部署笔记')).not.toBeNull()
|
||||
expect(match('项目 发布', '项目/部署笔记')).toBeNull()
|
||||
})
|
||||
|
||||
it('标题完全一致 > 标题开头 > 标题中间 > 目录 > 零散命中', () => {
|
||||
expect(rank('部署', ['部署/其他', '笔记/安装部署', '部署说明', '部 x 署', '部署'])).toEqual(['部署', '部署说明', '笔记/安装部署', '部署/其他', '部 x 署'])
|
||||
})
|
||||
|
||||
it('零散命中时紧凑、命中词首的排前', () => {
|
||||
expect(rank('rm', ['rawmark', 'readme', 'r-m'])[0]).toBe('r-m')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
// 快速切换的模糊匹配。目标形如"目录/标题",空白分隔的每个词都要命中;
|
||||
// 连续命中标题 > 连续命中目录 > 按顺序零散命中(如用首字母找笔记)
|
||||
|
||||
export interface FuzzyMatch {
|
||||
score: number
|
||||
// 命中字符在目标中的下标,用于高亮
|
||||
positions: number[]
|
||||
}
|
||||
|
||||
const BOUNDARY = new Set(['/', ' ', '-', '_', '.'])
|
||||
|
||||
function isBoundary(target: string, i: number): boolean {
|
||||
return i === 0 || BOUNDARY.has(target[i - 1])
|
||||
}
|
||||
|
||||
function matchTerm(term: string, target: string, lower: string, titleStart: number): FuzzyMatch | null {
|
||||
// 取最后一处连续命中:标题在末尾,优先落在标题上
|
||||
const at = lower.lastIndexOf(term)
|
||||
if (at >= 0) {
|
||||
let score = at >= titleStart ? 100 : 50
|
||||
if (at === titleStart) score += 30
|
||||
else if (isBoundary(target, at)) score += 10
|
||||
return { score, positions: Array.from(term, (_, k) => at + k) }
|
||||
}
|
||||
const positions: number[] = []
|
||||
let from = 0
|
||||
for (const ch of term) {
|
||||
const i = lower.indexOf(ch, from)
|
||||
if (i < 0) return null
|
||||
positions.push(i)
|
||||
from = i + 1
|
||||
}
|
||||
// 越分散得分越低,命中词首的字符加分
|
||||
const spread = positions[positions.length - 1] - positions[0] + 1 - positions.length
|
||||
const boundaries = positions.filter((i) => isBoundary(target, i)).length
|
||||
return { score: Math.max(1, 20 - spread + boundaries * 3), positions }
|
||||
}
|
||||
|
||||
/** titleStart 为标题在 target 中的起始下标;不匹配时返回 null */
|
||||
export function fuzzyMatch(query: string, target: string, titleStart: number): FuzzyMatch | null {
|
||||
const terms = query.toLowerCase().split(/\s+/).filter(Boolean)
|
||||
if (!terms.length) return null
|
||||
const lower = target.toLowerCase()
|
||||
let score = 0
|
||||
const positions = new Set<number>()
|
||||
for (const term of terms) {
|
||||
const m = matchTerm(term, target, lower, titleStart)
|
||||
if (!m) return null
|
||||
score += m.score
|
||||
m.positions.forEach((p) => positions.add(p))
|
||||
}
|
||||
// 标题与输入完全一致时排在最前
|
||||
if (lower.slice(titleStart) === query.trim().toLowerCase()) score += 1000
|
||||
return { score, positions: [...positions].sort((a, b) => a - b) }
|
||||
}
|
||||
+110
-20
@@ -29,6 +29,9 @@ export interface Toast {
|
||||
action?: { label: string; run: () => void }
|
||||
}
|
||||
|
||||
const RECENT_KEY = 'rawmark.recent'
|
||||
const RECENT_MAX = 30
|
||||
|
||||
export const ws = reactive({
|
||||
entries: [] as Entry[],
|
||||
tabs: [] as Tab[],
|
||||
@@ -39,6 +42,10 @@ export const ws = reactive({
|
||||
sidebarOpen: false,
|
||||
// 侧栏显示搜索而非目录树
|
||||
searchOpen: false,
|
||||
// 快速切换对话框
|
||||
switcherOpen: false,
|
||||
// 最近打开的笔记,最新的在前
|
||||
recent: JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as string[],
|
||||
toasts: [] as Toast[],
|
||||
})
|
||||
|
||||
@@ -279,30 +286,33 @@ export async function keepLocalVersion(path: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 搜索结果跳转 ----
|
||||
// ---- 打开并聚焦(快速切换、搜索结果跳转) ----
|
||||
|
||||
interface RevealTarget {
|
||||
path: string
|
||||
line: number
|
||||
terms: string[]
|
||||
}
|
||||
// 待定位的位置:笔记可能尚未载入,由编辑器面板在显示该笔记时执行
|
||||
// 笔记可能尚未载入或编辑器尚未显示,由编辑器面板在显示该笔记后执行
|
||||
let pendingReveal: RevealTarget | null = null
|
||||
|
||||
/** 打开笔记并定位到第 line 行(从 1 开始,0 表示不定位),选中该行第一个搜索词 */
|
||||
export async function openAt(path: string, line: number, terms: string[]) {
|
||||
pendingReveal = line > 0 ? { path, line, terms } : null
|
||||
if (pendingReveal && path === ws.active && view && editorStates.has(path)) {
|
||||
/** 打开笔记并聚焦编辑器;line > 0 时定位到该行(从 1 开始)并选中该行第一个搜索词 */
|
||||
export async function openAt(path: string, line = 0, terms: string[] = []) {
|
||||
pendingReveal = { path, line, terms }
|
||||
if (path === ws.active && view && editorStates.has(path)) {
|
||||
ws.sidebarOpen = false
|
||||
revealPending(view, path)
|
||||
} else await openNote(path)
|
||||
}
|
||||
|
||||
/** 编辑器面板显示笔记后调用;返回 true 表示已定位(不应再恢复原来的滚动位置) */
|
||||
/** 编辑器面板显示笔记后调用;返回 true 表示已定位到指定行(不应再恢复原来的滚动位置) */
|
||||
export function revealPending(v: EditorView, path: string): boolean {
|
||||
const r = pendingReveal
|
||||
if (!r || r.path !== path) return false
|
||||
pendingReveal = null
|
||||
// 触屏设备上聚焦会弹出软键盘遮住内容,只在有鼠标的设备上聚焦
|
||||
if (!matchMedia('(pointer: coarse)').matches) v.focus()
|
||||
if (r.line === 0) return false
|
||||
const line = v.state.doc.line(Math.min(r.line, v.state.doc.lines))
|
||||
const text = line.text.toLowerCase()
|
||||
let at = -1
|
||||
@@ -313,8 +323,6 @@ export function revealPending(v: EditorView, path: string): boolean {
|
||||
}
|
||||
const from = line.from + Math.max(at, 0)
|
||||
v.dispatch({ selection: { anchor: from, head: from + len }, effects: EditorView.scrollIntoView(from, { y: 'center' }) })
|
||||
// 触屏设备上聚焦会弹出软键盘遮住内容,只在有鼠标的设备上聚焦
|
||||
if (!matchMedia('(pointer: coarse)').matches) v.focus()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -388,26 +396,62 @@ function remapOpenNotes(from: string, to: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function remapRecent(from: string, to: string) {
|
||||
ws.recent = ws.recent.map((p) => remapPath(p, from, to) ?? p)
|
||||
}
|
||||
|
||||
function affected(path: string): string[] {
|
||||
return ws.tabs.map((t) => t.path).filter((p) => p === path || p.startsWith(path + '/'))
|
||||
}
|
||||
|
||||
// ---- 目录树 ----
|
||||
|
||||
// 目录刷新:同一时间只有一个请求;请求期间又有刷新请求或本地操作时,结果可能已过时,丢弃后重新获取。
|
||||
// 本地操作会先乐观修改目录,过时的结果会抹掉刚加入的条目(例如新建笔记的重命名框随之消失)。
|
||||
// 等待中的调用者在下一次被采用的结果(或请求失败)后统一完成
|
||||
let treeTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let treeFetching = false
|
||||
let treeAgain = false
|
||||
let localTreeEdits = 0
|
||||
const treeWaiters: (() => void)[] = []
|
||||
|
||||
export function refreshTree(): Promise<void> {
|
||||
clearTimeout(treeTimer)
|
||||
return new Promise((resolve) => {
|
||||
treeTimer = setTimeout(async () => {
|
||||
treeWaiters.push(resolve)
|
||||
scheduleTreeRefresh()
|
||||
})
|
||||
}
|
||||
|
||||
function scheduleTreeRefresh() {
|
||||
if (treeFetching) {
|
||||
treeAgain = true
|
||||
return
|
||||
}
|
||||
clearTimeout(treeTimer)
|
||||
treeTimer = setTimeout(() => void fetchTree(), 50)
|
||||
}
|
||||
|
||||
async function fetchTree() {
|
||||
treeFetching = true
|
||||
treeAgain = false
|
||||
const edits = localTreeEdits
|
||||
let entries: Entry[] | null = null
|
||||
try {
|
||||
ws.entries = await api.tree()
|
||||
if (opsInFlight === 0) markMissingTabsDeleted()
|
||||
entries = await api.tree()
|
||||
} catch (err) {
|
||||
toast('刷新目录失败:' + errorText(err))
|
||||
}
|
||||
resolve()
|
||||
}, 50)
|
||||
})
|
||||
treeFetching = false
|
||||
if (entries && (treeAgain || edits !== localTreeEdits || opsInFlight > 0)) {
|
||||
// 有操作进行中时由 withOp 在全部结束后重新调度
|
||||
if (opsInFlight === 0) scheduleTreeRefresh()
|
||||
return
|
||||
}
|
||||
if (entries) {
|
||||
ws.entries = entries
|
||||
markMissingTabsDeleted()
|
||||
}
|
||||
for (const resolve of treeWaiters.splice(0)) resolve()
|
||||
}
|
||||
|
||||
function markMissingTabsDeleted() {
|
||||
@@ -430,27 +474,52 @@ function uniqueName(dir: string, base: string, ext: string): string {
|
||||
|
||||
async function withOp<T>(fn: () => Promise<T>): Promise<T> {
|
||||
opsInFlight++
|
||||
localTreeEdits++
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
opsInFlight--
|
||||
localTreeEdits++
|
||||
if (--opsInFlight === 0 && treeWaiters.length) scheduleTreeRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
export async function createNote(dir: string) {
|
||||
const path = uniqueName(dir, '未命名', NOTE_EXT)
|
||||
ws.entries.push({ path, dir: false, size: 0, mtime: Date.now() })
|
||||
ws.renaming = path
|
||||
await withOp(async () => {
|
||||
await createNoteAt(path)
|
||||
}
|
||||
|
||||
// 进行中的新建:新建后立即出现重命名输入框,重命名须等文件真正创建后再执行
|
||||
const creating = new Map<string, Promise<boolean>>()
|
||||
|
||||
/** 在指定路径新建空笔记并打开,返回是否成功;不存在的上级目录由服务器创建 */
|
||||
export async function createNoteAt(path: string): Promise<boolean> {
|
||||
const done = createNoteFile(path)
|
||||
creating.set(path, done)
|
||||
try {
|
||||
return await done
|
||||
} finally {
|
||||
creating.delete(path)
|
||||
}
|
||||
}
|
||||
|
||||
async function createNoteFile(path: string): Promise<boolean> {
|
||||
const exists = new Set(ws.entries.map((e) => e.path))
|
||||
const added = [path]
|
||||
for (let d = dirname(path); d && !exists.has(d); d = dirname(d)) added.push(d)
|
||||
ws.entries.push(...added.map((p, i) => ({ path: p, dir: i > 0, size: 0, mtime: Date.now() })))
|
||||
return withOp(async () => {
|
||||
try {
|
||||
const rev = await api.writeFile(path, '', '')
|
||||
ws.tabs.push({ path, rev, status: 'ready', dirty: false })
|
||||
editorStates.set(path, buildState(path, ''))
|
||||
ws.active = path
|
||||
return true
|
||||
} catch (err) {
|
||||
ws.entries = ws.entries.filter((e) => e.path !== path)
|
||||
ws.entries = ws.entries.filter((e) => !added.includes(e.path))
|
||||
if (ws.renaming === path) ws.renaming = null
|
||||
toast('新建笔记失败:' + errorText(err))
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -480,6 +549,7 @@ export function renameTarget(path: string, name: string): string | null {
|
||||
|
||||
export async function move(from: string, to: string) {
|
||||
if (from === to) return
|
||||
if (creating.has(from) && !(await creating.get(from))) return
|
||||
if (ws.entries.some((e) => e.path === to)) {
|
||||
toast(`“${basename(to)}”已存在`)
|
||||
return
|
||||
@@ -492,11 +562,13 @@ export async function move(from: string, to: string) {
|
||||
const snapshot = ws.entries
|
||||
ws.entries = ws.entries.map((e) => ({ ...e, path: remapPath(e.path, from, to) ?? e.path }))
|
||||
remapOpenNotes(from, to)
|
||||
remapRecent(from, to)
|
||||
try {
|
||||
await api.move(from, to)
|
||||
} catch (err) {
|
||||
ws.entries = snapshot
|
||||
remapOpenNotes(to, from)
|
||||
remapRecent(to, from)
|
||||
toast('移动失败:' + errorText(err))
|
||||
return
|
||||
} finally {
|
||||
@@ -575,6 +647,7 @@ function handleEvents(evs: VaultEvent[]) {
|
||||
if (tab && tab.rev !== ev.rev) void syncOpenTab(tab)
|
||||
} else if (ev.type === 'moved') {
|
||||
if (tabOf(ev.from!) && !tabOf(ev.path!)) remapOpenNotes(ev.from!, ev.path!)
|
||||
remapRecent(ev.from!, ev.path!)
|
||||
} else if (ev.type === 'deleted') {
|
||||
const tab = tabOf(ev.path!)
|
||||
if (tab && tab.status === 'ready' && opsInFlight === 0) {
|
||||
@@ -640,6 +713,19 @@ export async function startWorkspace() {
|
||||
watch(
|
||||
() => [ws.tabs.map((t) => t.path), ws.active] as const,
|
||||
([paths, active]) => localStorage.setItem(TABS_KEY, JSON.stringify({ paths, active })),
|
||||
// 启动期间(如等待目录刷新时)打开的标签页也要立即记录
|
||||
{ immediate: true },
|
||||
)
|
||||
watch(
|
||||
() => ws.active,
|
||||
(p) => {
|
||||
if (p) ws.recent = [p, ...ws.recent.filter((r) => r !== p)].slice(0, RECENT_MAX)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
watch(
|
||||
() => ws.recent,
|
||||
(r) => localStorage.setItem(RECENT_KEY, JSON.stringify(r)),
|
||||
)
|
||||
// 页面隐藏/关闭前尽力发出待保存的修改(部分浏览器关闭标签页时只触发 pagehide)
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
@@ -655,6 +741,10 @@ export async function startWorkspace() {
|
||||
} else if (key === 'f' && e.shiftKey) {
|
||||
e.preventDefault()
|
||||
openSearch()
|
||||
} else if (key === 'p' && !e.shiftKey) {
|
||||
// 覆盖浏览器的打印快捷键
|
||||
e.preventDefault()
|
||||
ws.switcherOpen = !ws.switcherOpen
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user