Add persistent pinned tabs protected from bulk closing

This commit is contained in:
eezd committed 2026-10-22 12:11:08 +08:00
1 parent 19d0feac23
commit 82d08b9d73
4 files changed
+19 -13

No files matched your search

+1
View File
@@ -11,6 +11,7 @@ const paths = {
chevron: 'm9 6 6 6-6 6', chevron: 'm9 6 6 6-6 6',
more: 'M12 6h.01M12 12h.01M12 18h.01', more: 'M12 6h.01M12 12h.01M12 18h.01',
close: 'M18 6 6 18M6 6l12 12', close: 'M18 6 6 18M6 6l12 12',
pin: 'M16 9V3H8v6l-3 3v2h6v7h2v-7h6v-2z',
trash: 'M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6', trash: 'M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6',
logout: 'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9', logout: 'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9',
restore: 'M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5', restore: 'M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5',
+12 -10
View File
@@ -3,7 +3,7 @@ import { nextTick, ref } from 'vue'
import { commandTitle } from './commands' import { commandTitle } from './commands'
import Icon from './Icon.vue' import Icon from './Icon.vue'
import { noteTitle } from './paths' import { noteTitle } from './paths'
import { closeTab, toggleSourceMode, ws } from './workspace' import { closeTab, toggleSourceMode, ws, type Tab } from './workspace'
// 拖动排序:拖到某个标签的左半边插到它前面,右半边插到它后面 // 拖动排序:拖到某个标签的左半边插到它前面,右半边插到它后面
const TAB_TYPE = 'application/x-rawmark-tab' const TAB_TYPE = 'application/x-rawmark-tab'
@@ -42,20 +42,20 @@ function onDragEnd() {
} }
// ---- 右键菜单 ---- // ---- 右键菜单 ----
const menu = ref<{ path: string; x: number; y: number } | null>(null) const menu = ref<{ tab: Tab; x: number; y: number } | null>(null)
const menuElement = ref<HTMLElement>() const menuElement = ref<HTMLElement>()
async function openMenu(e: MouseEvent, path: string) { async function openMenu(e: MouseEvent, tab: Tab) {
menu.value = { path, x: e.clientX, y: e.clientY } menu.value = { tab, x: e.clientX, y: e.clientY }
await nextTick() await nextTick()
if (menu.value?.path !== path || !menuElement.value) return if (menu.value?.tab !== tab || !menuElement.value) return
const { width, height } = menuElement.value.getBoundingClientRect() const { width, height } = menuElement.value.getBoundingClientRect()
menu.value.x = Math.max(4, Math.min(e.clientX, innerWidth - width - 4)) menu.value.x = Math.max(4, Math.min(e.clientX, innerWidth - width - 4))
menu.value.y = Math.max(4, Math.min(e.clientY, innerHeight - height - 4)) menu.value.y = Math.max(4, Math.min(e.clientY, innerHeight - height - 4))
} }
async function closeMany(keep: (index: number, path: string) => boolean) { async function closeMany(keep: (index: number, path: string) => boolean) {
const paths = ws.tabs.filter((t, i) => !keep(i, t.path)).map((t) => t.path) const paths = ws.tabs.filter((t, i) => !t.pinned && !keep(i, t.path)).map((t) => t.path)
menu.value = null menu.value = null
for (const p of paths) await closeTab(p) for (const p of paths) await closeTab(p)
} }
@@ -91,12 +91,13 @@ function closeRight(path: string) {
draggable="true" draggable="true"
@click="ws.active = tab.path" @click="ws.active = tab.path"
@auxclick.prevent="$event.button === 1 && closeTab(tab.path)" @auxclick.prevent="$event.button === 1 && closeTab(tab.path)"
@contextmenu.prevent="openMenu($event, tab.path)" @contextmenu.prevent="openMenu($event, tab)"
@dragstart="onDragStart($event, tab.path)" @dragstart="onDragStart($event, tab.path)"
@dragover="onDragOver($event, tab.path)" @dragover="onDragOver($event, tab.path)"
@drop="onDrop" @drop="onDrop"
@dragend="onDragEnd" @dragend="onDragEnd"
> >
<span v-if="tab.pinned" class="rm-tab-pin" title="已固定"><Icon name="pin" :size="12" /></span>
<span class="rm-tab-title">{{ noteTitle(tab.path) }}</span> <span class="rm-tab-title">{{ noteTitle(tab.path) }}</span>
<button class="rm-tab-close" :class="{ dirty: tab.dirty }" title="关闭" @click.stop="closeTab(tab.path)"> <button class="rm-tab-close" :class="{ dirty: tab.dirty }" title="关闭" @click.stop="closeTab(tab.path)">
<span class="rm-dirty-dot" /> <span class="rm-dirty-dot" />
@@ -114,9 +115,10 @@ function closeRight(path: string) {
<template v-if="menu"> <template v-if="menu">
<div class="rm-menu-backdrop" @click="menu = null" @contextmenu.prevent="menu = null" /> <div class="rm-menu-backdrop" @click="menu = null" @contextmenu.prevent="menu = null" />
<div ref="menuElement" class="rm-menu rm-tab-menu" :style="{ left: menu.x + 'px', top: menu.y + 'px' }"> <div ref="menuElement" class="rm-menu rm-tab-menu" :style="{ left: menu.x + 'px', top: menu.y + 'px' }">
<button @click="closeMany((_, p) => p !== menu!.path)">关闭</button> <button @click="menu.tab.pinned = !menu.tab.pinned; menu = null">{{ menu.tab.pinned ? '取消固定' : '固定' }}</button>
<button @click="closeOthers(menu.path)">关闭其他标签页</button> <button @click="closeTab(menu.tab.path); menu = null">关闭</button>
<button @click="closeRight(menu.path)">关闭右侧标签页</button> <button @click="closeOthers(menu.tab.path)">关闭其他标签页</button>
<button @click="closeRight(menu.tab.path)">关闭右侧标签页</button>
</div> </div>
</template> </template>
</nav> </nav>
+1
View File
@@ -308,6 +308,7 @@ button {
.rm-tab:hover { background: var(--rm-hover); } .rm-tab:hover { background: var(--rm-hover); }
.rm-tab.active { background: var(--rm-active); color: var(--rm-text); } .rm-tab.active { background: var(--rm-active); color: var(--rm-text); }
.rm-tab-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .rm-tab-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rm-tab-pin { display: inline-flex; flex: none; color: var(--rm-accent-strong); }
.rm-tab-close { .rm-tab-close {
position: relative; position: relative;
display: inline-flex; display: inline-flex;
+5 -3
View File
@@ -22,6 +22,7 @@ export type TabStatus = 'loading' | 'ready' | 'conflict' | 'deleted' | 'error'
export interface Tab { export interface Tab {
path: string path: string
pinned?: boolean
// 最近一次与服务器一致的版本 // 最近一次与服务器一致的版本
rev: string rev: string
status: TabStatus status: TabStatus
@@ -957,7 +958,7 @@ export async function startWorkspace() {
const settingsLoaded = loadSettings().catch((err) => toast('读取设置失败:' + errorText(err))) const settingsLoaded = loadSettings().catch((err) => toast('读取设置失败:' + errorText(err)))
await refreshTree() await refreshTree()
await settingsLoaded await settingsLoaded
const saved = JSON.parse(localStorage.getItem(TABS_KEY) ?? '{"paths":[],"active":null}') as { paths: string[]; active: string | null } const saved = JSON.parse(localStorage.getItem(TABS_KEY) ?? '{"paths":[],"active":null}') as { paths: string[]; active: string | null; pinned?: string[] }
const exists = new Set(ws.entries.map((e) => e.path)) const exists = new Set(ws.entries.map((e) => e.path))
await Promise.all(saved.paths.filter((p) => exists.has(p)).map(openNote)) await Promise.all(saved.paths.filter((p) => exists.has(p)).map(openNote))
try { try {
@@ -965,6 +966,7 @@ export async function startWorkspace() {
} catch (err) { } catch (err) {
toast('恢复本地草稿失败:' + errorText(err)) toast('恢复本地草稿失败:' + errorText(err))
} }
for (const tab of ws.tabs) tab.pinned = saved.pinned?.includes(tab.path) ?? false
ws.active = saved.active && tabOf(saved.active) ? saved.active : (ws.tabs[0]?.path ?? null) ws.active = saved.active && tabOf(saved.active) ? saved.active : (ws.tabs[0]?.path ?? null)
let eventQueue = Promise.resolve() let eventQueue = Promise.resolve()
let stopped = false let stopped = false
@@ -991,8 +993,8 @@ export async function startWorkspace() {
if (listenersInstalled) return if (listenersInstalled) return
listenersInstalled = true listenersInstalled = true
watch( watch(
() => [ws.tabs.map((t) => t.path), ws.active] as const, () => ({ paths: ws.tabs.map((t) => t.path), active: ws.active, pinned: ws.tabs.filter((t) => t.pinned).map((t) => t.path) }),
([paths, active]) => localStorage.setItem(TABS_KEY, JSON.stringify({ paths, active })), (tabs) => localStorage.setItem(TABS_KEY, JSON.stringify(tabs)),
// 启动期间(如等待目录刷新时)打开的标签页也要立即记录 // 启动期间(如等待目录刷新时)打开的标签页也要立即记录
{ immediate: true }, { immediate: true },
) )