@opencode/ui 2.0.21 → 2.0.22
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/dist/context/dialog.d.ts +7 -3
- package/dist/data-display/project-avatar/project-avatar.d.ts +10 -0
- package/package.json +1 -1
- package/src/context/dialog.tsx +99 -52
- package/src/data-display/project-avatar/project-avatar.tsx +33 -0
- package/src/navigation/tabs/tabs.css +1 -18
- package/src/styles/tailwind/index.css +1 -0
- package/src/styles/theme.css +1 -0
package/dist/context/dialog.d.ts
CHANGED
|
@@ -14,8 +14,12 @@ export declare function useDialogLayer(): {
|
|
|
14
14
|
export declare function DialogProvider(props: ParentProps): JSX.Element;
|
|
15
15
|
export declare function useDialog(): {
|
|
16
16
|
readonly active: Active | undefined;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
/**
|
|
18
|
+
* id lets the caller close this dialog later rather than whichever is on top. Opening is deferred; once
|
|
19
|
+
* signal aborts, the open neither mounts nor replaces anything and onClose runs as for a replaced dialog.
|
|
20
|
+
*/
|
|
21
|
+
show(element: DialogElement, onClose?: () => void, id?: string, signal?: AbortSignal): Promise<void>;
|
|
22
|
+
push(element: DialogElement, onClose?: () => void, id?: string, signal?: AbortSignal): Promise<void>;
|
|
23
|
+
close(id?: string): void;
|
|
20
24
|
};
|
|
21
25
|
export {};
|
|
@@ -3,6 +3,16 @@ import "./project-avatar.css";
|
|
|
3
3
|
export declare const PROJECT_AVATAR_VARIANTS: readonly ["orange", "yellow", "cyan", "green", "red", "pink", "blue", "purple", "gray"];
|
|
4
4
|
export type ProjectAvatarVariant = (typeof PROJECT_AVATAR_VARIANTS)[number];
|
|
5
5
|
export type ProjectAvatarStyle = ProjectAvatarVariant | "outline";
|
|
6
|
+
export declare function getProjectAvatarSource(id?: string, icon?: {
|
|
7
|
+
color?: string;
|
|
8
|
+
url?: string;
|
|
9
|
+
override?: string;
|
|
10
|
+
}): string | undefined;
|
|
11
|
+
export declare function getProjectAvatarVariant(key?: string): ProjectAvatarVariant;
|
|
12
|
+
export declare const displayName: (project: {
|
|
13
|
+
name?: string;
|
|
14
|
+
worktree: string;
|
|
15
|
+
}) => string;
|
|
6
16
|
export interface ProjectAvatarProps extends ComponentProps<"div"> {
|
|
7
17
|
fallback: string;
|
|
8
18
|
src?: string;
|
package/package.json
CHANGED
package/src/context/dialog.tsx
CHANGED
|
@@ -37,35 +37,66 @@ export function useDialogLayer() {
|
|
|
37
37
|
|
|
38
38
|
function init() {
|
|
39
39
|
const [stack, setStack] = createSignal<Active[]>([])
|
|
40
|
-
|
|
40
|
+
// A dialog is closing from the moment its close starts until its exit animation ends and it is disposed.
|
|
41
|
+
const closing = new Map<string, ReturnType<typeof setTimeout> | undefined>()
|
|
42
|
+
// The same ids, reactive, so the top dialog that stays open owns the focus trap during an exit animation.
|
|
43
|
+
const [exiting, setExiting] = createSignal<ReadonlySet<string>>(new Set())
|
|
41
44
|
const lock = { value: false }
|
|
45
|
+
const state = { disposed: false }
|
|
46
|
+
|
|
47
|
+
// Detach the stack before disposing, so cleanups that close dialogs find nothing to admit. Every dialog is
|
|
48
|
+
// drained even when one throws. A replaced dialog that was not already closing still hears onClose.
|
|
49
|
+
const disposeAll = (notify: boolean) => {
|
|
50
|
+
const items = stack()
|
|
51
|
+
const exited = new Set(closing.keys())
|
|
52
|
+
setStack([])
|
|
53
|
+
closing.forEach((timer) => clearTimeout(timer))
|
|
54
|
+
closing.clear()
|
|
55
|
+
setExiting(new Set<string>())
|
|
56
|
+
items.forEach((item) => {
|
|
57
|
+
if (notify && !exited.has(item.id)) isolate(() => item.onClose?.())
|
|
58
|
+
isolate(item.dispose)
|
|
59
|
+
})
|
|
60
|
+
}
|
|
42
61
|
|
|
43
62
|
onCleanup(() => {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
timer.current = undefined
|
|
63
|
+
state.disposed = true
|
|
64
|
+
disposeAll(false)
|
|
47
65
|
})
|
|
48
66
|
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
67
|
+
const finish = (current: Active) => {
|
|
68
|
+
// Scheduled first, so the dialog still goes away when a callback throws.
|
|
69
|
+
closing.set(
|
|
70
|
+
current.id,
|
|
71
|
+
setTimeout(() => {
|
|
72
|
+
closing.delete(current.id)
|
|
73
|
+
if (closing.size === 0) lock.value = false
|
|
74
|
+
setExiting((ids) => new Set([...ids].filter((id) => id !== current.id)))
|
|
75
|
+
setStack((items) => items.filter((item) => item.id !== current.id))
|
|
76
|
+
current.dispose()
|
|
77
|
+
}, 100),
|
|
78
|
+
)
|
|
79
|
+
setExiting((ids) => new Set([...ids, current.id]))
|
|
80
|
+
isolate(() => current.onClose?.())
|
|
55
81
|
current.setClosing(true)
|
|
82
|
+
}
|
|
56
83
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
84
|
+
/** Programmatic close. Without an id it closes the top dialog, one at a time; with an id it never waits. */
|
|
85
|
+
const close = (id?: string) => {
|
|
86
|
+
const current = id ? stack().find((item) => item.id === id) : stack().at(-1)
|
|
87
|
+
if (!current || closing.has(current.id) || (!id && lock.value)) return
|
|
88
|
+
closing.set(current.id, undefined)
|
|
89
|
+
lock.value = true
|
|
90
|
+
finish(current)
|
|
91
|
+
}
|
|
62
92
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
93
|
+
/** Escape, a backdrop click, or Kobalte dismissing: only the top dialog, and one per exit animation. */
|
|
94
|
+
const dismiss = (id?: string) => {
|
|
95
|
+
const current = stack().at(-1)
|
|
96
|
+
if (!current || (id && current.id !== id) || closing.has(current.id) || lock.value) return
|
|
97
|
+
closing.set(current.id, undefined)
|
|
98
|
+
lock.value = true
|
|
99
|
+
finish(current)
|
|
69
100
|
}
|
|
70
101
|
|
|
71
102
|
createEffect(() => {
|
|
@@ -73,7 +104,7 @@ function init() {
|
|
|
73
104
|
|
|
74
105
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
75
106
|
if (event.key !== "Escape") return
|
|
76
|
-
|
|
107
|
+
dismiss()
|
|
77
108
|
event.preventDefault()
|
|
78
109
|
event.stopPropagation()
|
|
79
110
|
}
|
|
@@ -81,9 +112,17 @@ function init() {
|
|
|
81
112
|
makeEventListener(window, "keydown", onKeyDown, { capture: true })
|
|
82
113
|
})
|
|
83
114
|
|
|
84
|
-
const mount = (element: DialogElement, owner: Owner, onClose: (() => void) | undefined,
|
|
85
|
-
|
|
86
|
-
|
|
115
|
+
const mount = (element: DialogElement, owner: Owner, onClose: (() => void) | undefined, key?: string) => {
|
|
116
|
+
// A deferred open (e.g. from a focus callback) must not mount after the provider is gone.
|
|
117
|
+
if (state.disposed) return
|
|
118
|
+
const id = key ?? Math.random().toString(36).slice(2)
|
|
119
|
+
// The layer follows the dialog's current place in the stack, so a new top dialog always renders above.
|
|
120
|
+
const layer = () =>
|
|
121
|
+
Math.max(
|
|
122
|
+
0,
|
|
123
|
+
stack().findIndex((item) => item.id === id),
|
|
124
|
+
)
|
|
125
|
+
const zIndex = () => String(50 + layer() * 10)
|
|
87
126
|
let dispose: (() => void) | undefined
|
|
88
127
|
let setClosing: ((closing: boolean) => void) | undefined
|
|
89
128
|
|
|
@@ -96,27 +135,26 @@ function init() {
|
|
|
96
135
|
setClosing = setClosingSignal
|
|
97
136
|
return (
|
|
98
137
|
<Kobalte
|
|
99
|
-
modal={stack().
|
|
138
|
+
modal={stack().findLast((item) => !exiting().has(item.id))?.id === id}
|
|
100
139
|
open={!closing()}
|
|
101
140
|
onOpenChange={(open: boolean) => {
|
|
102
|
-
if (open
|
|
103
|
-
close(id)
|
|
141
|
+
if (!open) dismiss(id)
|
|
104
142
|
}}
|
|
105
143
|
>
|
|
106
144
|
<Kobalte.Portal>
|
|
107
145
|
<Kobalte.Overlay
|
|
108
146
|
data-component="dialog-overlay"
|
|
109
|
-
style={{ "z-index":
|
|
147
|
+
style={{ "z-index": zIndex() }}
|
|
110
148
|
onClick={() => {
|
|
111
|
-
if (backdropDismiss())
|
|
149
|
+
if (backdropDismiss()) dismiss(id)
|
|
112
150
|
}}
|
|
113
151
|
/>
|
|
114
152
|
<div
|
|
115
|
-
data-dialog-layer={layer}
|
|
153
|
+
data-dialog-layer={layer()}
|
|
116
154
|
style={{
|
|
117
155
|
position: "fixed",
|
|
118
156
|
inset: "0",
|
|
119
|
-
"z-index":
|
|
157
|
+
"z-index": zIndex(),
|
|
120
158
|
display: "flex",
|
|
121
159
|
"align-items": "center",
|
|
122
160
|
"justify-content": "center",
|
|
@@ -137,24 +175,15 @@ function init() {
|
|
|
137
175
|
setStack((items) => [...items, active])
|
|
138
176
|
}
|
|
139
177
|
|
|
140
|
-
const push = (element: DialogElement, owner: Owner, onClose?: () => void) => {
|
|
141
|
-
if (timer.current !== undefined) {
|
|
142
|
-
clearTimeout(timer.current)
|
|
143
|
-
timer.current = undefined
|
|
144
|
-
}
|
|
178
|
+
const push = (element: DialogElement, owner: Owner, onClose?: () => void, id?: string) => {
|
|
145
179
|
lock.value = false
|
|
146
|
-
mount(element, owner, onClose,
|
|
180
|
+
mount(element, owner, onClose, id)
|
|
147
181
|
}
|
|
148
182
|
|
|
149
|
-
const show = (element: DialogElement, owner: Owner, onClose?: () => void) => {
|
|
150
|
-
|
|
151
|
-
setStack([])
|
|
152
|
-
if (timer.current !== undefined) {
|
|
153
|
-
clearTimeout(timer.current)
|
|
154
|
-
timer.current = undefined
|
|
155
|
-
}
|
|
183
|
+
const show = (element: DialogElement, owner: Owner, onClose?: () => void, id?: string) => {
|
|
184
|
+
disposeAll(true)
|
|
156
185
|
lock.value = false
|
|
157
|
-
mount(element, owner, onClose,
|
|
186
|
+
mount(element, owner, onClose, id)
|
|
158
187
|
}
|
|
159
188
|
|
|
160
189
|
return {
|
|
@@ -164,6 +193,14 @@ function init() {
|
|
|
164
193
|
push,
|
|
165
194
|
}
|
|
166
195
|
}
|
|
196
|
+
/** Runs a dialog callback so its throw is reported without stopping the caller's cleanup. */
|
|
197
|
+
function isolate(fn: () => void) {
|
|
198
|
+
try {
|
|
199
|
+
fn()
|
|
200
|
+
} catch (error) {
|
|
201
|
+
console.error("[dialog]", error)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
167
204
|
|
|
168
205
|
export function DialogProvider(props: ParentProps) {
|
|
169
206
|
const ctx = init()
|
|
@@ -192,16 +229,26 @@ export function useDialog() {
|
|
|
192
229
|
get active() {
|
|
193
230
|
return ctx.stack().at(-1)
|
|
194
231
|
},
|
|
195
|
-
|
|
232
|
+
/**
|
|
233
|
+
* id lets the caller close this dialog later rather than whichever is on top. Opening is deferred; once
|
|
234
|
+
* signal aborts, the open neither mounts nor replaces anything and onClose runs as for a replaced dialog.
|
|
235
|
+
*/
|
|
236
|
+
show(element: DialogElement, onClose?: () => void, id?: string, signal?: AbortSignal) {
|
|
196
237
|
const base = ctx.stack().at(-1)?.owner ?? owner
|
|
197
|
-
return startTransition(() =>
|
|
238
|
+
return startTransition(() => {
|
|
239
|
+
if (signal?.aborted) return isolate(() => onClose?.())
|
|
240
|
+
ctx.show(element, base, onClose, id)
|
|
241
|
+
})
|
|
198
242
|
},
|
|
199
|
-
push(element: DialogElement, onClose?: () => void) {
|
|
243
|
+
push(element: DialogElement, onClose?: () => void, id?: string, signal?: AbortSignal) {
|
|
200
244
|
const base = ctx.stack().at(-1)?.owner ?? owner
|
|
201
|
-
return startTransition(() =>
|
|
245
|
+
return startTransition(() => {
|
|
246
|
+
if (signal?.aborted) return isolate(() => onClose?.())
|
|
247
|
+
ctx.push(element, base, onClose, id)
|
|
248
|
+
})
|
|
202
249
|
},
|
|
203
|
-
close() {
|
|
204
|
-
ctx.close()
|
|
250
|
+
close(id?: string) {
|
|
251
|
+
ctx.close(id)
|
|
205
252
|
},
|
|
206
253
|
}
|
|
207
254
|
}
|
|
@@ -29,6 +29,39 @@ export type ProjectAvatarVariant = (typeof PROJECT_AVATAR_VARIANTS)[number]
|
|
|
29
29
|
// "outline" is a neutral, muted style (e.g. recently closed projects) and is not part of the color rotation.
|
|
30
30
|
export type ProjectAvatarStyle = ProjectAvatarVariant | "outline"
|
|
31
31
|
|
|
32
|
+
const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
|
|
33
|
+
|
|
34
|
+
export function getProjectAvatarSource(id?: string, icon?: { color?: string; url?: string; override?: string }) {
|
|
35
|
+
if (id === OPENCODE_PROJECT_ID) return "https://opencode.ai/favicon.svg"
|
|
36
|
+
if (icon?.override) return icon.override
|
|
37
|
+
if (icon?.color) return undefined
|
|
38
|
+
return icon?.url
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant {
|
|
42
|
+
if (key === "mint") return "cyan"
|
|
43
|
+
if (key === "lime") return "green"
|
|
44
|
+
if (
|
|
45
|
+
key === "orange" ||
|
|
46
|
+
key === "yellow" ||
|
|
47
|
+
key === "cyan" ||
|
|
48
|
+
key === "green" ||
|
|
49
|
+
key === "red" ||
|
|
50
|
+
key === "pink" ||
|
|
51
|
+
key === "blue" ||
|
|
52
|
+
key === "purple" ||
|
|
53
|
+
key === "gray"
|
|
54
|
+
)
|
|
55
|
+
return key
|
|
56
|
+
return "gray"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const displayName = (project: { name?: string; worktree: string }) => {
|
|
60
|
+
const trimmed = project.worktree.replace(/[/\\]+$/, "")
|
|
61
|
+
const filename = trimmed.slice(Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")) + 1)
|
|
62
|
+
return project.name || filename || project.worktree
|
|
63
|
+
}
|
|
64
|
+
|
|
32
65
|
export interface ProjectAvatarProps extends ComponentProps<"div"> {
|
|
33
66
|
fallback: string
|
|
34
67
|
src?: string
|
|
@@ -211,11 +211,6 @@
|
|
|
211
211
|
transform 120ms ease;
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
-
&[data-value="review"] {
|
|
215
|
-
padding-left: 8px;
|
|
216
|
-
padding-right: 8px;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
214
|
[data-slot="tabs-trigger"] {
|
|
220
215
|
height: 100%;
|
|
221
216
|
padding: 0 !important;
|
|
@@ -552,18 +547,6 @@
|
|
|
552
547
|
}
|
|
553
548
|
}
|
|
554
549
|
|
|
555
|
-
&[data-value="open-file"],
|
|
556
|
-
&[data-value^="file://"],
|
|
557
|
-
&[data-value="browser"],
|
|
558
|
-
&[data-value^="browser:"] {
|
|
559
|
-
gap: 8px;
|
|
560
|
-
padding-inline-end: 4px;
|
|
561
|
-
|
|
562
|
-
[data-slot="tabs-trigger-close-button"] {
|
|
563
|
-
margin-inline-start: 0;
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
|
|
567
550
|
&:has([data-selected]) {
|
|
568
551
|
background-color: var(--v2-background-bg-layer-02);
|
|
569
552
|
box-shadow: none;
|
|
@@ -581,7 +564,7 @@
|
|
|
581
564
|
font-variation-settings: "slnt" 0;
|
|
582
565
|
font-variant-numeric: tabular-nums;
|
|
583
566
|
|
|
584
|
-
&[data-selected]
|
|
567
|
+
&[data-selected] {
|
|
585
568
|
color: var(--v2-text-text-base);
|
|
586
569
|
}
|
|
587
570
|
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
@source not "../../../../desktop/src/renderer/**/*.test.{ts,tsx}";
|
|
11
11
|
@source "../../../../enterprise/src";
|
|
12
12
|
@source "../../../../session-ui/src";
|
|
13
|
+
@source "../../../../gui-extensions/src";
|
|
13
14
|
@source not "../../../../session-ui/src/**/*.test.{ts,tsx}";
|
|
14
15
|
@source "../../../../storybook";
|
|
15
16
|
@source "../../../src";
|