@anionex/dsh-pinned-sessions 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +44 -0
- package/README.zh.md +44 -0
- package/cordis.patch.yml +4 -0
- package/lib/client.js +794 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +3 -0
- package/lib/index.js.map +1 -0
- package/lib/types/client/dom-bridge.d.ts +54 -0
- package/lib/types/client/index.d.ts +61 -0
- package/lib/types/client/pin-store.d.ts +27 -0
- package/lib/types/client/presentation.d.ts +8 -0
- package/lib/types/index.d.ts +2 -0
- package/package.json +97 -0
- package/scripts/clean.mjs +6 -0
- package/scripts/wrap-client.mjs +18 -0
- package/src/client/dom-bridge.ts +242 -0
- package/src/client/index.tsx +445 -0
- package/src/client/pin-store.ts +116 -0
- package/src/client/presentation.ts +27 -0
- package/src/index.ts +2 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { Pin as PinNode, PinOff as PinOffNode, type IconNode } from 'lucide'
|
|
2
|
+
|
|
3
|
+
export const SIDEBAR_SLOT_SELECTOR = '[data-slot="sidebar.workspaces"]'
|
|
4
|
+
export const PINNED_HOST_ATTRIBUTE = 'data-dsh-pinned-sessions-host'
|
|
5
|
+
export const MENU_HOST_ATTRIBUTE = 'data-dsh-pinned-session-menu-host'
|
|
6
|
+
export const MENU_OWNER_ATTRIBUTE = 'data-dsh-pinned-session-menu'
|
|
7
|
+
|
|
8
|
+
export interface SessionsForCapture {
|
|
9
|
+
readonly list: {
|
|
10
|
+
getSnapshot(): { readonly current?: string | undefined }
|
|
11
|
+
}
|
|
12
|
+
open(sessionId: string): void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SessionActionTarget {
|
|
16
|
+
readonly button: HTMLButtonElement
|
|
17
|
+
readonly row: HTMLElement
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SessionMenuTarget {
|
|
21
|
+
readonly host: HTMLDivElement
|
|
22
|
+
readonly menu: HTMLElement
|
|
23
|
+
readonly sessionId: string
|
|
24
|
+
readonly button: HTMLButtonElement
|
|
25
|
+
readonly icon: HTMLSpanElement
|
|
26
|
+
readonly label: HTMLSpanElement
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface TriggerRect {
|
|
30
|
+
readonly top: number
|
|
31
|
+
readonly right: number
|
|
32
|
+
readonly bottom: number
|
|
33
|
+
readonly left: number
|
|
34
|
+
readonly width: number
|
|
35
|
+
readonly height: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function elementFromTarget(target: EventTarget | null): Element | null {
|
|
39
|
+
return target !== null && (target as Node).nodeType === 1 ? target as Element : null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Identify a native ellipsis trigger inside a real Session row. */
|
|
43
|
+
export function findSessionActionTarget(target: EventTarget | null): SessionActionTarget | null {
|
|
44
|
+
const element = elementFromTarget(target)
|
|
45
|
+
const button = element?.closest('button')
|
|
46
|
+
if (!(button instanceof HTMLButtonElement)) return null
|
|
47
|
+
const row = button.closest<HTMLElement>('[role="treeitem"][aria-selected]')
|
|
48
|
+
if (row === null || row === button) return null
|
|
49
|
+
if (row.closest(SIDEBAR_SLOT_SELECTOR) === null) return null
|
|
50
|
+
if (row.closest(`[${PINNED_HOST_ATTRIBUTE}]`) !== null) return null
|
|
51
|
+
return { button, row }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Ask the native row closure for its Session ID without changing selection.
|
|
56
|
+
* The Workspace browser's row callback calls sessions.open(node.id), so a
|
|
57
|
+
* synchronous, verified interceptor captures the exact ID and is restored
|
|
58
|
+
* before the user's original ellipsis click continues.
|
|
59
|
+
*/
|
|
60
|
+
export function captureSessionId(row: HTMLElement, sessions: SessionsForCapture): string | null {
|
|
61
|
+
const hadOwn = Object.prototype.hasOwnProperty.call(sessions, 'open')
|
|
62
|
+
const ownDescriptor = hadOwn ? Object.getOwnPropertyDescriptor(sessions, 'open') : undefined
|
|
63
|
+
const original = sessions.open
|
|
64
|
+
let captured: string | null = null
|
|
65
|
+
const interceptor = (sessionId: string): void => {
|
|
66
|
+
if (typeof sessionId === 'string' && sessionId.length > 0) captured = sessionId
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
Object.defineProperty(sessions, 'open', {
|
|
71
|
+
configurable: true,
|
|
72
|
+
enumerable: ownDescriptor?.enumerable ?? false,
|
|
73
|
+
writable: true,
|
|
74
|
+
value: interceptor,
|
|
75
|
+
})
|
|
76
|
+
if (sessions.open !== interceptor) return null
|
|
77
|
+
row.click()
|
|
78
|
+
} catch {
|
|
79
|
+
return null
|
|
80
|
+
} finally {
|
|
81
|
+
try {
|
|
82
|
+
if (hadOwn && ownDescriptor !== undefined) Object.defineProperty(sessions, 'open', ownDescriptor)
|
|
83
|
+
else delete (sessions as { open?: SessionsForCapture['open'] }).open
|
|
84
|
+
} catch {
|
|
85
|
+
try {
|
|
86
|
+
Object.defineProperty(sessions, 'open', {
|
|
87
|
+
configurable: true,
|
|
88
|
+
writable: true,
|
|
89
|
+
value: original,
|
|
90
|
+
})
|
|
91
|
+
} catch {
|
|
92
|
+
// The verified patch path is configurable; this is a defensive fallback.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return captured
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Insert the pinned region immediately after the native Workspace header. */
|
|
100
|
+
export function ensurePinnedHost(doc: Document): HTMLDivElement | null {
|
|
101
|
+
const slot = doc.querySelector<HTMLElement>(SIDEBAR_SLOT_SELECTOR)
|
|
102
|
+
const root = slot?.firstElementChild
|
|
103
|
+
if (!(root instanceof HTMLElement)) return null
|
|
104
|
+
const existing = root.querySelector<HTMLDivElement>(`:scope > [${PINNED_HOST_ATTRIBUTE}]`)
|
|
105
|
+
if (existing !== null) return existing
|
|
106
|
+
const header = root.firstElementChild
|
|
107
|
+
if (header === null) return null
|
|
108
|
+
const host = doc.createElement('div')
|
|
109
|
+
host.setAttribute(PINNED_HOST_ATTRIBUTE, '')
|
|
110
|
+
header.insertAdjacentElement('afterend', host)
|
|
111
|
+
return host
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** List direct-body portal menus so one trigger can exclude every pre-existing menu. */
|
|
115
|
+
export function listPortalMenus(doc: Document): readonly HTMLElement[] {
|
|
116
|
+
return [...doc.body.children].filter((node): node is HTMLElement => (
|
|
117
|
+
node instanceof HTMLElement && node.getAttribute('role') === 'menu'
|
|
118
|
+
))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function belongsToTrigger(menu: HTMLElement, trigger: TriggerRect | undefined): boolean {
|
|
122
|
+
if (trigger === undefined || trigger.width === 0 || trigger.height === 0) return true
|
|
123
|
+
const menuRect = menu.getBoundingClientRect()
|
|
124
|
+
if (menuRect.width === 0 || menuRect.height === 0) return true
|
|
125
|
+
const horizontalGap = Math.min(
|
|
126
|
+
Math.abs(menuRect.left - trigger.left),
|
|
127
|
+
Math.abs(menuRect.right - trigger.right),
|
|
128
|
+
)
|
|
129
|
+
const verticalGap = Math.max(
|
|
130
|
+
0,
|
|
131
|
+
menuRect.top - trigger.bottom,
|
|
132
|
+
trigger.top - menuRect.bottom,
|
|
133
|
+
)
|
|
134
|
+
return horizontalGap <= 64 && verticalGap <= 32
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Find only a new, geometrically associated portal menu after one trigger click. */
|
|
138
|
+
export function findUnclaimedPortalMenu(
|
|
139
|
+
doc: Document,
|
|
140
|
+
excluded: ReadonlySet<HTMLElement> = new Set(),
|
|
141
|
+
trigger?: TriggerRect,
|
|
142
|
+
): HTMLElement | null {
|
|
143
|
+
const menus = listPortalMenus(doc).filter(menu => (
|
|
144
|
+
!excluded.has(menu)
|
|
145
|
+
&& !menu.hasAttribute(MENU_OWNER_ATTRIBUTE)
|
|
146
|
+
&& belongsToTrigger(menu, trigger)
|
|
147
|
+
))
|
|
148
|
+
return menus.at(-1) ?? null
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function createLucideIcon(doc: Document, node: IconNode, name: string): SVGSVGElement {
|
|
152
|
+
const [, attributes, children = []] = node
|
|
153
|
+
const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
|
154
|
+
for (const [key, value] of Object.entries(attributes)) svg.setAttribute(key, String(value))
|
|
155
|
+
svg.setAttribute('width', '16')
|
|
156
|
+
svg.setAttribute('height', '16')
|
|
157
|
+
svg.setAttribute('stroke-width', '1.8')
|
|
158
|
+
svg.setAttribute('class', `lucide lucide-${name}`)
|
|
159
|
+
svg.setAttribute('aria-hidden', 'true')
|
|
160
|
+
for (const [tag, childAttributes] of children) {
|
|
161
|
+
const child = doc.createElementNS('http://www.w3.org/2000/svg', tag)
|
|
162
|
+
for (const [key, value] of Object.entries(childAttributes)) child.setAttribute(key, String(value))
|
|
163
|
+
svg.appendChild(child)
|
|
164
|
+
}
|
|
165
|
+
return svg
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Update the unmanaged item without moving it into the plugin's React tree. */
|
|
169
|
+
export function updateSessionMenuItem(target: SessionMenuTarget, pinned: boolean, label: string): void {
|
|
170
|
+
target.button.setAttribute('aria-label', label)
|
|
171
|
+
target.label.textContent = label
|
|
172
|
+
target.icon.replaceChildren(createLucideIcon(
|
|
173
|
+
target.menu.ownerDocument,
|
|
174
|
+
pinned ? PinOffNode : PinNode,
|
|
175
|
+
pinned ? 'pin-off' : 'pin',
|
|
176
|
+
))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Add an unmanaged native-styled row before the Session menu's archive tail. */
|
|
180
|
+
export function attachSessionMenuHost(menu: HTMLElement, sessionId: string): SessionMenuTarget | null {
|
|
181
|
+
if (menu.hasAttribute(MENU_OWNER_ATTRIBUTE)) return null
|
|
182
|
+
const viewport = [...menu.children].find((node): node is HTMLElement => (
|
|
183
|
+
node instanceof HTMLElement && node.getAttribute('role') === 'presentation'
|
|
184
|
+
))
|
|
185
|
+
if (viewport === undefined) return null
|
|
186
|
+
const wrappers = [...viewport.children].filter((node): node is HTMLElement => (
|
|
187
|
+
node instanceof HTMLElement
|
|
188
|
+
&& node.querySelector(':scope > button[role="menuitem"]') instanceof HTMLButtonElement
|
|
189
|
+
))
|
|
190
|
+
if (wrappers.length < 3 || wrappers.length > 4) return null
|
|
191
|
+
const templateButton = wrappers[0]?.querySelector<HTMLButtonElement>(':scope > button[role="menuitem"]')
|
|
192
|
+
if (templateButton === null || templateButton === undefined) return null
|
|
193
|
+
const spans = [...templateButton.children].filter((node): node is HTMLSpanElement => node instanceof HTMLSpanElement)
|
|
194
|
+
if (spans.length < 2) return null
|
|
195
|
+
|
|
196
|
+
const doc = menu.ownerDocument
|
|
197
|
+
const host = doc.createElement('div')
|
|
198
|
+
host.className = wrappers[0]?.className ?? ''
|
|
199
|
+
host.setAttribute(MENU_HOST_ATTRIBUTE, '')
|
|
200
|
+
const button = doc.createElement('button')
|
|
201
|
+
button.type = 'button'
|
|
202
|
+
button.setAttribute('role', 'menuitem')
|
|
203
|
+
button.className = `${templateButton.className} dsh-pins-menu-button`
|
|
204
|
+
const icon = doc.createElement('span')
|
|
205
|
+
icon.className = spans[0]?.className ?? ''
|
|
206
|
+
icon.setAttribute('aria-hidden', 'true')
|
|
207
|
+
const label = doc.createElement('span')
|
|
208
|
+
label.className = spans.at(-1)?.className ?? ''
|
|
209
|
+
button.append(icon, label)
|
|
210
|
+
host.appendChild(button)
|
|
211
|
+
viewport.insertBefore(host, wrappers[2] ?? null)
|
|
212
|
+
menu.setAttribute(MENU_OWNER_ATTRIBUTE, sessionId)
|
|
213
|
+
|
|
214
|
+
const target = { host, menu, sessionId, button, icon, label }
|
|
215
|
+
const view = doc.defaultView
|
|
216
|
+
if (view !== null) view.dispatchEvent(new view.Event('resize'))
|
|
217
|
+
return target
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Restore useful keyboard focus after a pinned row removes itself. */
|
|
221
|
+
export function focusAfterPinnedRemoval(doc: Document, host: HTMLElement | null, removedIndex: number): boolean {
|
|
222
|
+
const remaining = host === null ? [] : [...host.querySelectorAll<HTMLButtonElement>('.dsh-pins-open')]
|
|
223
|
+
const next = remaining[Math.min(removedIndex, remaining.length - 1)]
|
|
224
|
+
if (next !== undefined) {
|
|
225
|
+
next.focus({ preventScroll: true })
|
|
226
|
+
return true
|
|
227
|
+
}
|
|
228
|
+
const nativeRow = doc.querySelector<HTMLElement>(`${SIDEBAR_SLOT_SELECTOR} [role="treeitem"][aria-selected="true"]`)
|
|
229
|
+
?? doc.querySelector<HTMLElement>(`${SIDEBAR_SLOT_SELECTOR} [role="treeitem"]`)
|
|
230
|
+
if (nativeRow === null) return false
|
|
231
|
+
const hadTabIndex = nativeRow.hasAttribute('tabindex')
|
|
232
|
+
if (!hadTabIndex) nativeRow.tabIndex = -1
|
|
233
|
+
nativeRow.focus({ preventScroll: true })
|
|
234
|
+
if (doc.activeElement === nativeRow) return true
|
|
235
|
+
if (!hadTabIndex) nativeRow.removeAttribute('tabindex')
|
|
236
|
+
return false
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function removeBridgeArtifacts(doc: Document): void {
|
|
240
|
+
for (const node of doc.querySelectorAll(`[${PINNED_HOST_ATTRIBUTE}], [${MENU_HOST_ATTRIBUTE}]`)) node.remove()
|
|
241
|
+
for (const menu of doc.querySelectorAll(`[${MENU_OWNER_ATTRIBUTE}]`)) menu.removeAttribute(MENU_OWNER_ATTRIBUTE)
|
|
242
|
+
}
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useEffect,
|
|
3
|
+
useLayoutEffect,
|
|
4
|
+
useMemo,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
useSyncExternalStore,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from 'react'
|
|
10
|
+
import { Pin, X } from 'lucide-react'
|
|
11
|
+
import { createPortal } from 'react-dom'
|
|
12
|
+
import {
|
|
13
|
+
attachSessionMenuHost,
|
|
14
|
+
captureSessionId,
|
|
15
|
+
ensurePinnedHost,
|
|
16
|
+
findSessionActionTarget,
|
|
17
|
+
findUnclaimedPortalMenu,
|
|
18
|
+
focusAfterPinnedRemoval,
|
|
19
|
+
listPortalMenus,
|
|
20
|
+
MENU_OWNER_ATTRIBUTE,
|
|
21
|
+
removeBridgeArtifacts,
|
|
22
|
+
SIDEBAR_SLOT_SELECTOR,
|
|
23
|
+
updateSessionMenuItem,
|
|
24
|
+
type SessionMenuTarget,
|
|
25
|
+
type TriggerRect,
|
|
26
|
+
type SessionsForCapture,
|
|
27
|
+
} from './dom-bridge.js'
|
|
28
|
+
import { PIN_STORAGE_KEY, PinStore } from './pin-store.js'
|
|
29
|
+
import { sessionDisplayTitle } from './presentation.js'
|
|
30
|
+
|
|
31
|
+
interface SessionSummaryLike {
|
|
32
|
+
readonly id: string
|
|
33
|
+
readonly title?: string | undefined
|
|
34
|
+
readonly cwd?: string | undefined
|
|
35
|
+
readonly blank?: boolean | undefined
|
|
36
|
+
readonly running?: boolean | undefined
|
|
37
|
+
readonly completed?: boolean | undefined
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface SessionsSnapshotLike {
|
|
41
|
+
readonly ids: readonly string[]
|
|
42
|
+
readonly byId: Readonly<Record<string, SessionSummaryLike | undefined>>
|
|
43
|
+
readonly current?: string | undefined
|
|
44
|
+
readonly phase: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface WorkspaceLike {
|
|
48
|
+
readonly workspaceId: string
|
|
49
|
+
readonly title: string
|
|
50
|
+
readonly sessionIds: readonly string[]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface WorkspacesSnapshotLike {
|
|
54
|
+
readonly items: readonly WorkspaceLike[]
|
|
55
|
+
readonly archivedSessionIds: readonly string[]
|
|
56
|
+
readonly phase: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type SelectorHook<T> = <S>(selector: (snapshot: T) => S, equal?: (left: S, right: S) => boolean) => S
|
|
60
|
+
type Translate = (key: string, params?: Readonly<Record<string, unknown>>) => string
|
|
61
|
+
|
|
62
|
+
interface SlotsLike {
|
|
63
|
+
inject(name: string, install: () => unknown): void
|
|
64
|
+
register<I, P>(
|
|
65
|
+
entry: {
|
|
66
|
+
readonly name: string
|
|
67
|
+
readonly id: string
|
|
68
|
+
readonly order?: number
|
|
69
|
+
readonly locale?: string
|
|
70
|
+
readonly inject?: () => I
|
|
71
|
+
},
|
|
72
|
+
component: (props: P) => ReactNode,
|
|
73
|
+
): () => void
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface LocaleLike {
|
|
77
|
+
register(namespace: string, dictionaries: Readonly<Record<string, Readonly<Record<string, string>>>>): () => void
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface ClientContextLike {
|
|
81
|
+
readonly slots: SlotsLike
|
|
82
|
+
readonly sessions: SessionsForCapture
|
|
83
|
+
readonly locale: LocaleLike
|
|
84
|
+
effect(setup: () => (() => void), label?: string): unknown
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface BridgeProps {
|
|
88
|
+
readonly store: PinStore
|
|
89
|
+
readonly sessions: SessionsForCapture
|
|
90
|
+
readonly useSessions: SelectorHook<SessionsSnapshotLike>
|
|
91
|
+
readonly useWorkspaces: SelectorHook<WorkspacesSnapshotLike>
|
|
92
|
+
readonly t: Translate
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface PinIconProps {
|
|
96
|
+
readonly size?: number
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface PendingMenuRequest {
|
|
100
|
+
readonly sessionId: string
|
|
101
|
+
readonly createdAt: number
|
|
102
|
+
readonly existingMenus: ReadonlySet<HTMLElement>
|
|
103
|
+
readonly triggerRect: TriggerRect
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface ActiveMenuBinding {
|
|
107
|
+
readonly target: SessionMenuTarget
|
|
108
|
+
readonly onClick: (event: MouseEvent) => void
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const NS = 'pinnedSessions'
|
|
112
|
+
const STYLE_ID = '@anionex/dsh-pinned-sessions'
|
|
113
|
+
const MENU_ASSOCIATION_MS = 2_000
|
|
114
|
+
|
|
115
|
+
const zh = {
|
|
116
|
+
'menu.pin': '置顶会话',
|
|
117
|
+
'menu.unpin': '取消置顶',
|
|
118
|
+
'section.label': '置顶会话',
|
|
119
|
+
'session.open': '打开置顶会话“{name}”',
|
|
120
|
+
'session.unpin': '取消置顶“{name}”',
|
|
121
|
+
'workspace.ungrouped': '未分组',
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const en = {
|
|
125
|
+
'menu.pin': 'Pin session',
|
|
126
|
+
'menu.unpin': 'Unpin session',
|
|
127
|
+
'section.label': 'Pinned sessions',
|
|
128
|
+
'session.open': 'Open pinned session “{name}”',
|
|
129
|
+
'session.unpin': 'Unpin “{name}”',
|
|
130
|
+
'workspace.ungrouped': 'Ungrouped',
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const styles = `
|
|
134
|
+
[${'data-dsh-pinned-sessions-host'}]{box-sizing:border-box;min-width:0;flex:none}
|
|
135
|
+
.dsh-pins-section{box-sizing:border-box;min-width:0;margin:0 4px 6px;padding:0 0 6px;border-bottom:1px solid var(--dsw-alias-border-l1)}
|
|
136
|
+
.dsh-pins-header{display:flex;align-items:center;gap:6px;height:28px;padding:0 8px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;font-weight:500}
|
|
137
|
+
.dsh-pins-header svg{flex:none;color:var(--dsw-alias-state-business-primary)}
|
|
138
|
+
.dsh-pins-list{box-sizing:border-box;max-height:192px;min-width:0;margin:0;padding:0;overflow-y:auto;overscroll-behavior:contain;list-style:none}
|
|
139
|
+
.dsh-pins-row{box-sizing:border-box;display:flex;align-items:center;gap:0;height:32px;min-width:0;border-radius:6px;color:var(--dsw-alias-label-primary)}
|
|
140
|
+
.dsh-pins-row:hover,.dsh-pins-row[data-current="true"]{background:var(--dsw-alias-interactive-bg-hover)}
|
|
141
|
+
.dsh-pins-open{box-sizing:border-box;display:flex;align-items:center;gap:6px;min-width:0;height:32px;padding:0 4px 0 8px;border:0;background:transparent;color:inherit;cursor:pointer;flex:1;text-align:left}
|
|
142
|
+
.dsh-pins-open:focus-visible,.dsh-pins-remove:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:-2px}
|
|
143
|
+
.dsh-pins-open>svg{flex:none;color:var(--dsw-alias-state-business-primary)}
|
|
144
|
+
.dsh-pins-title{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px;line-height:20px;flex:1}
|
|
145
|
+
.dsh-pins-workspace{max-width:34%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:18px;flex:none}
|
|
146
|
+
.dsh-pins-remove{display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;margin-right:3px;padding:0;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;opacity:0;flex:none}
|
|
147
|
+
.dsh-pins-row:hover .dsh-pins-remove,.dsh-pins-remove:focus-visible{opacity:1}
|
|
148
|
+
.dsh-pins-remove:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
|
|
149
|
+
.dsh-pins-menu-button{letter-spacing:0}
|
|
150
|
+
@media (max-width:767px){.dsh-pins-workspace{display:none}.dsh-pins-remove{opacity:1}}
|
|
151
|
+
`
|
|
152
|
+
|
|
153
|
+
export const inject = ['slots', 'sessions', 'locale']
|
|
154
|
+
|
|
155
|
+
/** Register the lifecycle bridge in the additive frame overlay slot. */
|
|
156
|
+
export function apply(ctx: ClientContextLike): void {
|
|
157
|
+
let storage: Storage | undefined
|
|
158
|
+
try {
|
|
159
|
+
storage = window.localStorage
|
|
160
|
+
} catch {
|
|
161
|
+
storage = undefined
|
|
162
|
+
}
|
|
163
|
+
const store = new PinStore(storage)
|
|
164
|
+
|
|
165
|
+
ctx.effect(() => {
|
|
166
|
+
const onStorage = (event: StorageEvent): void => {
|
|
167
|
+
if (event.key === PIN_STORAGE_KEY || event.key === null) store.reload()
|
|
168
|
+
}
|
|
169
|
+
window.addEventListener('storage', onStorage)
|
|
170
|
+
return () => { window.removeEventListener('storage', onStorage) }
|
|
171
|
+
}, 'pinned-sessions: cross-tab state')
|
|
172
|
+
|
|
173
|
+
ctx.effect(() => {
|
|
174
|
+
if (document.querySelector(`style[data-plugin-css="${STYLE_ID}"]`) !== null) return () => {}
|
|
175
|
+
const tag = document.createElement('style')
|
|
176
|
+
tag.dataset.plugin = '@anionex/dsh-pinned-sessions'
|
|
177
|
+
tag.dataset.pluginCss = STYLE_ID
|
|
178
|
+
tag.textContent = styles
|
|
179
|
+
document.head.appendChild(tag)
|
|
180
|
+
return () => { tag.remove() }
|
|
181
|
+
}, 'pinned-sessions: styles')
|
|
182
|
+
|
|
183
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'pinned-sessions: dictionaries')
|
|
184
|
+
|
|
185
|
+
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
|
|
186
|
+
name: 'shell.overlay',
|
|
187
|
+
id: 'pinned-sessions-bridge',
|
|
188
|
+
order: 40,
|
|
189
|
+
locale: NS,
|
|
190
|
+
inject: () => ({ store, sessions: ctx.sessions }),
|
|
191
|
+
}, PinnedSessionsBridge))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Keep native behavior intact while mounting the sidebar portal and unmanaged menu item. */
|
|
195
|
+
export function PinnedSessionsBridge({ store, sessions, useSessions, useWorkspaces, t }: BridgeProps): ReactNode {
|
|
196
|
+
const sessionSnapshot = useSessions(snapshot => snapshot)
|
|
197
|
+
const workspaceSnapshot = useWorkspaces(snapshot => snapshot)
|
|
198
|
+
const pins = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot)
|
|
199
|
+
const [sidebarHost, setSidebarHost] = useState<HTMLDivElement | null>(null)
|
|
200
|
+
const [sidebarWide, setSidebarWide] = useState(true)
|
|
201
|
+
const pending = useRef<PendingMenuRequest | null>(null)
|
|
202
|
+
const activeMenu = useRef<ActiveMenuBinding | null>(null)
|
|
203
|
+
const focusAfterUnpin = useRef<number | null>(null)
|
|
204
|
+
|
|
205
|
+
useLayoutEffect(() => {
|
|
206
|
+
let active = true
|
|
207
|
+
let queued = false
|
|
208
|
+
let settleTimer: number | undefined
|
|
209
|
+
let observedSidebar: Element | null = null
|
|
210
|
+
const sidebarObserver = new MutationObserver(() => { queueRefresh() })
|
|
211
|
+
|
|
212
|
+
const disposeActiveMenu = (): void => {
|
|
213
|
+
const binding = activeMenu.current
|
|
214
|
+
if (binding === null) return
|
|
215
|
+
activeMenu.current = null
|
|
216
|
+
binding.target.button.removeEventListener('click', binding.onClick)
|
|
217
|
+
binding.target.host.remove()
|
|
218
|
+
if (binding.target.menu.getAttribute(MENU_OWNER_ATTRIBUTE) === binding.target.sessionId) {
|
|
219
|
+
binding.target.menu.removeAttribute(MENU_OWNER_ATTRIBUTE)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const updateActiveMenu = (): void => {
|
|
223
|
+
const binding = activeMenu.current
|
|
224
|
+
if (binding === null) return
|
|
225
|
+
const pinned = store.isPinned(binding.target.sessionId)
|
|
226
|
+
updateSessionMenuItem(binding.target, pinned, t(pinned ? 'menu.unpin' : 'menu.pin'))
|
|
227
|
+
}
|
|
228
|
+
const observeSidebar = (): void => {
|
|
229
|
+
const slot = document.querySelector(SIDEBAR_SLOT_SELECTOR)
|
|
230
|
+
if (slot === observedSidebar) return
|
|
231
|
+
sidebarObserver.disconnect()
|
|
232
|
+
observedSidebar = slot
|
|
233
|
+
if (slot !== null) {
|
|
234
|
+
sidebarObserver.observe(slot, {
|
|
235
|
+
attributes: true,
|
|
236
|
+
attributeFilter: ['class', 'style'],
|
|
237
|
+
childList: true,
|
|
238
|
+
subtree: true,
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const refresh = (): void => {
|
|
243
|
+
if (!active) return
|
|
244
|
+
const nextHost = ensurePinnedHost(document)
|
|
245
|
+
setSidebarHost(current => current === nextHost ? current : nextHost)
|
|
246
|
+
observeSidebar()
|
|
247
|
+
const rootWidth = nextHost?.parentElement?.getBoundingClientRect().width
|
|
248
|
+
if (rootWidth !== undefined) setSidebarWide(rootWidth >= 160)
|
|
249
|
+
|
|
250
|
+
const binding = activeMenu.current
|
|
251
|
+
if (binding !== null && (!binding.target.host.isConnected || !binding.target.menu.isConnected)) {
|
|
252
|
+
disposeActiveMenu()
|
|
253
|
+
}
|
|
254
|
+
const request = pending.current
|
|
255
|
+
if (request === null) return
|
|
256
|
+
if (Date.now() - request.createdAt > MENU_ASSOCIATION_MS) {
|
|
257
|
+
pending.current = null
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
const menu = findUnclaimedPortalMenu(document, request.existingMenus, request.triggerRect)
|
|
261
|
+
if (menu === null) return
|
|
262
|
+
const target = attachSessionMenuHost(menu, request.sessionId)
|
|
263
|
+
if (target === null) return
|
|
264
|
+
const onClick = (event: MouseEvent): void => {
|
|
265
|
+
event.preventDefault()
|
|
266
|
+
event.stopPropagation()
|
|
267
|
+
store.toggle(target.sessionId)
|
|
268
|
+
disposeActiveMenu()
|
|
269
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
|
270
|
+
}
|
|
271
|
+
target.button.addEventListener('click', onClick)
|
|
272
|
+
activeMenu.current = { target, onClick }
|
|
273
|
+
pending.current = null
|
|
274
|
+
updateActiveMenu()
|
|
275
|
+
}
|
|
276
|
+
function queueRefresh(): void {
|
|
277
|
+
if (!active) return
|
|
278
|
+
if (settleTimer === undefined) {
|
|
279
|
+
settleTimer = window.setTimeout(() => {
|
|
280
|
+
settleTimer = undefined
|
|
281
|
+
refresh()
|
|
282
|
+
}, 250)
|
|
283
|
+
}
|
|
284
|
+
if (queued) return
|
|
285
|
+
queued = true
|
|
286
|
+
queueMicrotask(() => {
|
|
287
|
+
queued = false
|
|
288
|
+
refresh()
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
const onClickCapture = (event: MouseEvent): void => {
|
|
292
|
+
pending.current = null
|
|
293
|
+
const target = findSessionActionTarget(event.target)
|
|
294
|
+
if (target === null) return
|
|
295
|
+
const sessionId = captureSessionId(target.row, sessions)
|
|
296
|
+
if (sessionId === null) return
|
|
297
|
+
const rect = target.button.getBoundingClientRect()
|
|
298
|
+
pending.current = {
|
|
299
|
+
sessionId,
|
|
300
|
+
createdAt: Date.now(),
|
|
301
|
+
existingMenus: new Set(listPortalMenus(document)),
|
|
302
|
+
triggerRect: {
|
|
303
|
+
top: rect.top,
|
|
304
|
+
right: rect.right,
|
|
305
|
+
bottom: rect.bottom,
|
|
306
|
+
left: rect.left,
|
|
307
|
+
width: rect.width,
|
|
308
|
+
height: rect.height,
|
|
309
|
+
},
|
|
310
|
+
}
|
|
311
|
+
queueRefresh()
|
|
312
|
+
}
|
|
313
|
+
const onKeyDownCapture = (): void => { pending.current = null }
|
|
314
|
+
|
|
315
|
+
refresh()
|
|
316
|
+
document.addEventListener('click', onClickCapture, true)
|
|
317
|
+
document.addEventListener('keydown', onKeyDownCapture, true)
|
|
318
|
+
const bodyObserver = new MutationObserver(() => { queueRefresh() })
|
|
319
|
+
bodyObserver.observe(document.body, { childList: true })
|
|
320
|
+
const unsubscribe = store.subscribe(updateActiveMenu)
|
|
321
|
+
return () => {
|
|
322
|
+
active = false
|
|
323
|
+
pending.current = null
|
|
324
|
+
if (settleTimer !== undefined) window.clearTimeout(settleTimer)
|
|
325
|
+
unsubscribe()
|
|
326
|
+
bodyObserver.disconnect()
|
|
327
|
+
sidebarObserver.disconnect()
|
|
328
|
+
document.removeEventListener('click', onClickCapture, true)
|
|
329
|
+
document.removeEventListener('keydown', onKeyDownCapture, true)
|
|
330
|
+
disposeActiveMenu()
|
|
331
|
+
removeBridgeArtifacts(document)
|
|
332
|
+
}
|
|
333
|
+
}, [sessions, store, t])
|
|
334
|
+
|
|
335
|
+
useLayoutEffect(() => {
|
|
336
|
+
if (sidebarHost === null) return
|
|
337
|
+
const root = sidebarHost.parentElement
|
|
338
|
+
if (root === null) return
|
|
339
|
+
const update = (): void => { setSidebarWide(root.getBoundingClientRect().width >= 160) }
|
|
340
|
+
update()
|
|
341
|
+
if (typeof ResizeObserver === 'undefined') return
|
|
342
|
+
const observer = new ResizeObserver(update)
|
|
343
|
+
observer.observe(root)
|
|
344
|
+
return () => { observer.disconnect() }
|
|
345
|
+
}, [sidebarHost])
|
|
346
|
+
|
|
347
|
+
useEffect(() => {
|
|
348
|
+
if (sessionSnapshot.phase !== 'ready' || workspaceSnapshot.phase !== 'ready') return
|
|
349
|
+
const valid = new Set(sessionSnapshot.ids)
|
|
350
|
+
const archived = new Set(workspaceSnapshot.archivedSessionIds)
|
|
351
|
+
store.prune(sessionId => valid.has(sessionId) && !archived.has(sessionId))
|
|
352
|
+
}, [sessionSnapshot, store, workspaceSnapshot])
|
|
353
|
+
|
|
354
|
+
const rows = useMemo(() => {
|
|
355
|
+
const workspaceBySession = new Map<string, string>()
|
|
356
|
+
for (const workspace of workspaceSnapshot.items) {
|
|
357
|
+
for (const sessionId of workspace.sessionIds) {
|
|
358
|
+
if (!workspaceBySession.has(sessionId)) workspaceBySession.set(sessionId, workspace.title)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return pins.flatMap(pin => {
|
|
362
|
+
const session = sessionSnapshot.byId[pin.id]
|
|
363
|
+
if (session === undefined || session.blank === true) return []
|
|
364
|
+
const workspaceTitle = workspaceBySession.get(pin.id)
|
|
365
|
+
const workspace = workspaceTitle ?? t('workspace.ungrouped')
|
|
366
|
+
return [{ session, title: sessionDisplayTitle(session, workspaceTitle), workspace }]
|
|
367
|
+
})
|
|
368
|
+
}, [pins, sessionSnapshot.byId, t, workspaceSnapshot.items])
|
|
369
|
+
|
|
370
|
+
useLayoutEffect(() => {
|
|
371
|
+
const removedIndex = focusAfterUnpin.current
|
|
372
|
+
if (removedIndex === null) return
|
|
373
|
+
focusAfterUnpin.current = null
|
|
374
|
+
focusAfterPinnedRemoval(document, sidebarHost, removedIndex)
|
|
375
|
+
}, [rows, sidebarHost])
|
|
376
|
+
|
|
377
|
+
return sidebarHost !== null && sidebarHost.isConnected && sidebarWide && rows.length > 0
|
|
378
|
+
? createPortal(
|
|
379
|
+
<PinnedSessionSection
|
|
380
|
+
currentId={sessionSnapshot.current}
|
|
381
|
+
rows={rows}
|
|
382
|
+
open={sessionId => { sessions.open(sessionId) }}
|
|
383
|
+
unpin={(sessionId, index) => {
|
|
384
|
+
focusAfterUnpin.current = index
|
|
385
|
+
store.toggle(sessionId)
|
|
386
|
+
}}
|
|
387
|
+
t={t}
|
|
388
|
+
/>,
|
|
389
|
+
sidebarHost,
|
|
390
|
+
)
|
|
391
|
+
: null
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
interface PinnedSessionSectionProps {
|
|
395
|
+
readonly currentId?: string | undefined
|
|
396
|
+
readonly rows: readonly { readonly session: SessionSummaryLike; readonly title: string; readonly workspace: string }[]
|
|
397
|
+
readonly open: (sessionId: string) => void
|
|
398
|
+
readonly unpin: (sessionId: string, index: number) => void
|
|
399
|
+
readonly t: Translate
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function PinnedSessionSection({ currentId, rows, open, unpin, t }: PinnedSessionSectionProps): ReactNode {
|
|
403
|
+
return <section className="dsh-pins-section" aria-label={t('section.label')}>
|
|
404
|
+
<div className="dsh-pins-header">
|
|
405
|
+
<PinIcon size={13} />
|
|
406
|
+
<span>{t('section.label')}</span>
|
|
407
|
+
</div>
|
|
408
|
+
<ul className="dsh-pins-list">
|
|
409
|
+
{rows.map(({ session, title, workspace }, index) => <li
|
|
410
|
+
className="dsh-pins-row"
|
|
411
|
+
data-current={session.id === currentId ? 'true' : 'false'}
|
|
412
|
+
key={session.id}
|
|
413
|
+
>
|
|
414
|
+
<button
|
|
415
|
+
type="button"
|
|
416
|
+
className="dsh-pins-open"
|
|
417
|
+
aria-current={session.id === currentId ? 'page' : undefined}
|
|
418
|
+
aria-label={t('session.open', { name: title })}
|
|
419
|
+
onClick={() => { open(session.id) }}
|
|
420
|
+
>
|
|
421
|
+
<PinIcon size={13} />
|
|
422
|
+
<span className="dsh-pins-title">{title}</span>
|
|
423
|
+
<span className="dsh-pins-workspace">{workspace}</span>
|
|
424
|
+
</button>
|
|
425
|
+
<button
|
|
426
|
+
type="button"
|
|
427
|
+
className="dsh-pins-remove"
|
|
428
|
+
aria-label={t('session.unpin', { name: title })}
|
|
429
|
+
title={t('session.unpin', { name: title })}
|
|
430
|
+
onClick={() => { unpin(session.id, index) }}
|
|
431
|
+
>
|
|
432
|
+
<CloseIcon />
|
|
433
|
+
</button>
|
|
434
|
+
</li>)}
|
|
435
|
+
</ul>
|
|
436
|
+
</section>
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function PinIcon({ size = 16 }: PinIconProps): ReactNode {
|
|
440
|
+
return <Pin size={size} strokeWidth={1.8} aria-hidden="true" />
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function CloseIcon(): ReactNode {
|
|
444
|
+
return <X size={14} strokeWidth={2} aria-hidden="true" />
|
|
445
|
+
}
|