|
1 | 1 | <script lang="ts"> |
2 | | - import { X, Plus } from 'lucide-svelte'; |
| 2 | + import { X, Plus, ListTodo, Target, FileDiff, FolderTree, GitBranch, Terminal } from 'lucide-svelte'; |
3 | 3 | import IconButton from '$lib/ui/IconButton.svelte'; |
4 | 4 | import GoalPanel from './GoalPanel.svelte'; |
5 | 5 | import PlanPanel from './PlanPanel.svelte'; |
|
18 | 18 | }: { goal: Goal | null; plan?: PlanStep[]; cwd?: string; changed?: string[]; onRevertFile?: (p: string) => void } = $props(); |
19 | 19 |
|
20 | 20 | const PANELS = [ |
21 | | - { key: 'plan', label: '计划' }, |
22 | | - { key: 'goal', label: '目标' }, |
23 | | - { key: 'changes', label: '改动' }, |
24 | | - { key: 'files', label: '文件' }, |
25 | | - { key: 'git', label: 'Git' }, |
26 | | - { key: 'term', label: '终端' } |
| 21 | + { key: 'plan', label: '计划', icon: ListTodo }, |
| 22 | + { key: 'goal', label: '目标', icon: Target }, |
| 23 | + { key: 'changes', label: '改动', icon: FileDiff }, |
| 24 | + { key: 'files', label: '文件', icon: FolderTree }, |
| 25 | + { key: 'git', label: 'Git', icon: GitBranch }, |
| 26 | + { key: 'term', label: '终端', icon: Terminal } |
27 | 27 | ]; |
28 | 28 | const labelOf = (key: string) => PANELS.find((p) => p.key === key)?.label ?? key; |
29 | 29 |
|
30 | | - function loadTabs(): string[] { |
| 30 | + // A tab is an *instance* of a panel, so several tabs can share a panel type |
| 31 | + // (e.g. two terminals); each carries its own id. |
| 32 | + type Tab = { id: string; panel: string }; |
| 33 | + let counter = 0; |
| 34 | + const newId = () => `t${Date.now().toString(36)}-${(counter++).toString(36)}`; |
| 35 | +
|
| 36 | + function loadTabs(): Tab[] { |
31 | 37 | try { |
32 | | - const t = JSON.parse(localStorage.getItem('jucode-dock-tabs') || 'null'); |
33 | | - if (Array.isArray(t)) return t.filter((k) => PANELS.some((p) => p.key === k)); |
| 38 | + const raw = JSON.parse(localStorage.getItem('jucode-dock-tabs') || 'null'); |
| 39 | + if (Array.isArray(raw)) { |
| 40 | + const tabs = raw |
| 41 | + .map((t): Tab | null => { |
| 42 | + if (typeof t === 'string') return { id: newId(), panel: t }; // migrate old format |
| 43 | + if (t && typeof t.id === 'string' && typeof t.panel === 'string') return { id: t.id, panel: t.panel }; |
| 44 | + return null; |
| 45 | + }) |
| 46 | + .filter((t): t is Tab => t !== null && PANELS.some((p) => p.key === t.panel)); |
| 47 | + if (tabs.length) return tabs; |
| 48 | + } |
34 | 49 | } catch { |
35 | 50 | /* ignore */ |
36 | 51 | } |
37 | | - return ['goal']; |
| 52 | + return [{ id: newId(), panel: 'goal' }]; |
38 | 53 | } |
39 | 54 |
|
40 | | - let openTabs = $state<string[]>(loadTabs()); |
| 55 | + let openTabs = $state<Tab[]>(loadTabs()); |
41 | 56 | let active = $state( |
42 | 57 | (() => { |
43 | 58 | const saved = localStorage.getItem('jucode-dock-active'); |
44 | | - return saved && openTabs.includes(saved) ? saved : (openTabs[0] ?? ''); |
| 59 | + return saved && openTabs.some((t) => t.id === saved) ? saved : (openTabs[0]?.id ?? ''); |
45 | 60 | })() |
46 | 61 | ); |
47 | 62 | let addOpen = $state(false); |
48 | | - let dragKey = $state<string | null>(null); |
| 63 | + let dragId = $state<string | null>(null); |
49 | 64 | let bar = $state<HTMLElement | null>(null); |
50 | 65 |
|
51 | | - const available = $derived(PANELS.filter((p) => !openTabs.includes(p.key))); |
| 66 | + // Number repeated panels so duplicates are distinguishable (终端 1 / 终端 2). |
| 67 | + function tabLabel(tab: Tab): string { |
| 68 | + const base = labelOf(tab.panel); |
| 69 | + const same = openTabs.filter((t) => t.panel === tab.panel); |
| 70 | + return same.length < 2 ? base : `${base} ${same.indexOf(tab) + 1}`; |
| 71 | + } |
52 | 72 |
|
53 | 73 | $effect(() => { |
54 | 74 | localStorage.setItem('jucode-dock-tabs', JSON.stringify(openTabs)); |
55 | 75 | localStorage.setItem('jucode-dock-active', active); |
56 | 76 | }); |
57 | 77 |
|
58 | | - function openPanel(key: string) { |
59 | | - if (!openTabs.includes(key)) openTabs = [...openTabs, key]; |
60 | | - active = key; |
| 78 | + function openPanel(panel: string) { |
| 79 | + const id = newId(); |
| 80 | + openTabs = [...openTabs, { id, panel }]; |
| 81 | + active = id; |
61 | 82 | addOpen = false; |
62 | 83 | } |
63 | | - function closeTab(key: string) { |
64 | | - const idx = openTabs.indexOf(key); |
65 | | - openTabs = openTabs.filter((k) => k !== key); |
66 | | - if (active === key) active = openTabs[Math.min(idx, openTabs.length - 1)] ?? ''; |
| 84 | + function closeTab(id: string) { |
| 85 | + const idx = openTabs.findIndex((t) => t.id === id); |
| 86 | + openTabs = openTabs.filter((t) => t.id !== id); |
| 87 | + if (active === id) active = openTabs[Math.min(idx, openTabs.length - 1)]?.id ?? ''; |
67 | 88 | } |
68 | | - function startDrag(e: PointerEvent, key: string) { |
| 89 | + function startDrag(e: PointerEvent, id: string) { |
69 | 90 | if (e.button !== 0) return; |
70 | | - dragKey = key; |
| 91 | + dragId = id; |
71 | 92 | const move = (ev: PointerEvent) => { |
72 | 93 | if (!bar) return; |
73 | 94 | const tabs = [...bar.querySelectorAll<HTMLElement>('[data-tab]')]; |
74 | 95 | const over = tabs.find((el) => { |
75 | 96 | const r = el.getBoundingClientRect(); |
76 | 97 | return ev.clientX >= r.left && ev.clientX <= r.right; |
77 | 98 | }); |
78 | | - const overKey = over?.dataset.tab; |
79 | | - if (overKey && overKey !== dragKey) { |
80 | | - const from = openTabs.indexOf(dragKey!); |
81 | | - const to = openTabs.indexOf(overKey); |
| 99 | + const overId = over?.dataset.tab; |
| 100 | + if (overId && overId !== dragId) { |
| 101 | + const from = openTabs.findIndex((t) => t.id === dragId); |
| 102 | + const to = openTabs.findIndex((t) => t.id === overId); |
82 | 103 | const arr = [...openTabs]; |
83 | 104 | arr.splice(to, 0, arr.splice(from, 1)[0]); |
84 | 105 | openTabs = arr; |
85 | 106 | } |
86 | 107 | }; |
87 | 108 | const up = () => { |
88 | | - dragKey = null; |
| 109 | + dragId = null; |
89 | 110 | window.removeEventListener('pointermove', move); |
90 | 111 | window.removeEventListener('pointerup', up); |
91 | 112 | }; |
|
97 | 118 | <div class="dock"> |
98 | 119 | <div class="tabbar"> |
99 | 120 | <div class="tabs" bind:this={bar}> |
100 | | - {#each openTabs as key (key)} |
| 121 | + {#each openTabs as tab (tab.id)} |
101 | 122 | <div |
102 | 123 | class="tab" |
103 | | - class:on={key === active} |
104 | | - class:dragging={key === dragKey} |
105 | | - data-tab={key} |
| 124 | + class:on={tab.id === active} |
| 125 | + class:dragging={tab.id === dragId} |
| 126 | + data-tab={tab.id} |
106 | 127 | role="tab" |
107 | 128 | tabindex="0" |
108 | | - aria-selected={key === active} |
109 | | - onpointerdown={(e) => startDrag(e, key)} |
110 | | - onclick={() => (active = key)} |
111 | | - onkeydown={(e) => e.key === 'Enter' && (active = key)} |
| 129 | + aria-selected={tab.id === active} |
| 130 | + onpointerdown={(e) => startDrag(e, tab.id)} |
| 131 | + onclick={() => (active = tab.id)} |
| 132 | + onkeydown={(e) => e.key === 'Enter' && (active = tab.id)} |
112 | 133 | > |
113 | | - <span class="tdot" class:on={key === active}></span> |
114 | | - <span class="tlabel">{labelOf(key)}</span> |
| 134 | + <span class="tdot" class:on={tab.id === active}></span> |
| 135 | + <span class="tlabel">{tabLabel(tab)}</span> |
115 | 136 | <IconButton |
116 | 137 | size="sm" |
117 | 138 | label="close tab" |
118 | 139 | onpointerdown={(e: PointerEvent) => e.stopPropagation()} |
119 | 140 | onclick={(e: MouseEvent) => { |
120 | 141 | e.stopPropagation(); |
121 | | - closeTab(key); |
| 142 | + closeTab(tab.id); |
122 | 143 | }}><X size={12} /></IconButton> |
123 | 144 | </div> |
124 | 145 | {/each} |
125 | 146 | </div> |
126 | 147 | <div class="add"> |
127 | | - <IconButton onclick={() => (addOpen = !addOpen)} label="add panel" disabled={available.length === 0}><Plus size={15} /></IconButton> |
| 148 | + <IconButton onclick={() => (addOpen = !addOpen)} label="add panel"><Plus size={15} /></IconButton> |
128 | 149 | {#if addOpen} |
129 | 150 | <button class="add-backdrop" aria-label="close" onclick={() => (addOpen = false)}></button> |
130 | 151 | <div class="add-menu"> |
131 | | - {#each available as p (p.key)} |
| 152 | + {#each PANELS as p (p.key)} |
132 | 153 | <button class="add-item" onclick={() => openPanel(p.key)}>{p.label}</button> |
133 | 154 | {/each} |
134 | 155 | </div> |
|
137 | 158 | </div> |
138 | 159 |
|
139 | 160 | <div class="content"> |
140 | | - {#each openTabs as key (key)} |
141 | | - <div class="pane" class:hidden={key !== active}> |
142 | | - {#if key === 'plan'}<PlanPanel {plan} /> |
143 | | - {:else if key === 'goal'}<GoalPanel {goal} /> |
144 | | - {:else if key === 'changes'}<ChangesPanel {cwd} files={changed} onRevert={onRevertFile} /> |
145 | | - {:else if key === 'files'}<FilesPanel rootDir={cwd} /> |
146 | | - {:else if key === 'git'}<GitPanel {cwd} /> |
147 | | - {:else if key === 'term'}<TerminalPanel {cwd} />{/if} |
| 161 | + {#each openTabs as tab (tab.id)} |
| 162 | + <div class="pane" class:hidden={tab.id !== active}> |
| 163 | + {#if tab.panel === 'plan'}<PlanPanel {plan} /> |
| 164 | + {:else if tab.panel === 'goal'}<GoalPanel {goal} /> |
| 165 | + {:else if tab.panel === 'changes'}<ChangesPanel {cwd} files={changed} onRevert={onRevertFile} /> |
| 166 | + {:else if tab.panel === 'files'}<FilesPanel rootDir={cwd} /> |
| 167 | + {:else if tab.panel === 'git'}<GitPanel {cwd} /> |
| 168 | + {:else if tab.panel === 'term'}<TerminalPanel {cwd} />{/if} |
148 | 169 | </div> |
149 | 170 | {/each} |
150 | 171 | {#if openTabs.length === 0} |
151 | 172 | <div class="empty"> |
152 | 173 | <p>没有打开的面板</p> |
153 | | - <span>点 <b>+</b> 打开一个</span> |
| 174 | + <span>选一个面板打开,或点右上角 <b>+</b></span> |
| 175 | + <div class="empty-grid"> |
| 176 | + {#each PANELS as p (p.key)} |
| 177 | + <button class="pcardbtn" onclick={() => openPanel(p.key)}> |
| 178 | + <p.icon size={18} /> |
| 179 | + <span>{p.label}</span> |
| 180 | + </button> |
| 181 | + {/each} |
| 182 | + </div> |
154 | 183 | </div> |
155 | 184 | {/if} |
156 | 185 | </div> |
|
302 | 331 | .empty b { |
303 | 332 | color: var(--accent-bright); |
304 | 333 | } |
| 334 | + .empty { |
| 335 | + padding: 24px 18px; |
| 336 | + } |
| 337 | + .empty-grid { |
| 338 | + display: grid; |
| 339 | + grid-template-columns: 1fr 1fr; |
| 340 | + gap: 8px; |
| 341 | + width: 100%; |
| 342 | + max-width: 260px; |
| 343 | + margin-top: 14px; |
| 344 | + } |
| 345 | + .pcardbtn { |
| 346 | + display: flex; |
| 347 | + flex-direction: column; |
| 348 | + align-items: center; |
| 349 | + gap: 8px; |
| 350 | + padding: 15px 10px; |
| 351 | + border: 1px solid var(--border); |
| 352 | + border-radius: var(--r-md); |
| 353 | + background: var(--surface); |
| 354 | + color: var(--dim); |
| 355 | + font-size: 12.5px; |
| 356 | + cursor: pointer; |
| 357 | + transition: border-color 0.12s, color 0.12s, background 0.12s; |
| 358 | + } |
| 359 | + .pcardbtn:hover { |
| 360 | + border-color: color-mix(in oklab, var(--accent) 45%, var(--border)); |
| 361 | + color: var(--text); |
| 362 | + background: var(--surface2); |
| 363 | + } |
305 | 364 | </style> |
0 commit comments