@aryacode/dsh-aryacode-polish 0.5.0 → 0.6.2

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.
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The folder list behind "On this PC".
3
+ *
4
+ * Why this exists at all: the add-a-workspace flow is driven by the
5
+ * `sidebar.workspaces.directoryFlow` slot, whose single occupant is a directory
6
+ * picker. A picker serves exactly one capability — `browse` (`list`,
7
+ * `createDirectory`) or `native` (`pick`) — and the browser build composes the
8
+ * one without a chooser to show, so `pick` there waits for a dialog that never
9
+ * opens. Owning the listing instead of borrowing it keeps the row behaving the
10
+ * same in the app and in the browser, and keeps the plugin free of a dependency
11
+ * on which picker a profile happens to mount.
12
+ *
13
+ * The route is the same documented shape the GitHub half uses: a registered
14
+ * `connection.fetch` path, answering JSON, with the browser addressing the
15
+ * document-relative form.
16
+ */
17
+ import { promises as fs } from 'node:fs'
18
+ import { homedir } from 'node:os'
19
+ import { dirname, join, resolve } from 'node:path'
20
+ import { json } from './github.js'
21
+
22
+ /** Absolute path the Host registers; the browser uses {@link FOLDERS_ROUTE}. */
23
+ export const FOLDERS_PATH = '/api/polish.folders'
24
+
25
+ /** Document-relative form the bundle calls with `fetch`. */
26
+ export const FOLDERS_ROUTE = FOLDERS_PATH.slice(1)
27
+
28
+ /**
29
+ * Places a folder browser can start from.
30
+ *
31
+ * @returns labelled roots, the profile's home first.
32
+ */
33
+ export function folderRoots(home = homedir()) {
34
+ const roots = [{ label: 'Home', path: home }]
35
+ const drive = process.env.SystemDrive ?? 'C:'
36
+ if (drive !== '') roots.push({ label: `${drive}\\`, path: `${drive}\\` })
37
+ return roots
38
+ }
39
+
40
+ /**
41
+ * List the subdirectories of one directory.
42
+ *
43
+ * @param input - a directory path; the client passes what the previous listing
44
+ * returned, and never a path it composed itself.
45
+ * @returns the resolved path, its parent (null at a drive root) and the child
46
+ * folders sorted by name, hidden entries excluded.
47
+ */
48
+ export async function listFolders(input) {
49
+ if (typeof input !== 'string' || input === '') {
50
+ throw new Error('a folder path is required')
51
+ }
52
+ const path = resolve(input)
53
+ const entries = []
54
+ for (const entry of await fs.readdir(path, { withFileTypes: true })) {
55
+ if (entry.name.startsWith('.') || entry.name.startsWith('$')) continue
56
+ if (entry.isDirectory() === false) continue
57
+ entries.push({ name: entry.name, path: join(path, entry.name) })
58
+ }
59
+ entries.sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: 'base' }))
60
+ const parent = dirname(path)
61
+ return { path, parent: parent === path ? null : parent, entries }
62
+ }
63
+
64
+ /**
65
+ * Answer one request from the browser.
66
+ *
67
+ * @param body - `{ action: 'roots' }` or `{ action: 'ls', path }`.
68
+ * @returns a Response with a JSON body.
69
+ */
70
+ export async function handleFolders(body) {
71
+ const action = body?.action
72
+ if (action === 'roots') return json({ roots: folderRoots() })
73
+ if (action === 'ls') {
74
+ try {
75
+ return json(await listFolders(body.path))
76
+ } catch (error) {
77
+ return json({ error: error instanceof Error ? error.message : 'cannot list that folder' }, 400)
78
+ }
79
+ }
80
+ return json({ error: `unknown action ${String(action)}` }, 400)
81
+ }
82
+
83
+ /** The host half mounted by the `workspaceSources` feature. */
84
+ export const foldersFeature = {
85
+ name: 'polish-folders',
86
+ inject: ['connection'],
87
+ apply(ctx) {
88
+ ctx.effect(() => ctx.connection.fetch.register({
89
+ path: FOLDERS_PATH,
90
+ methods: ['POST'],
91
+ requestBody: 'buffered',
92
+ fetch: async request => {
93
+ if (request.method !== 'POST') return json({ error: 'POST required' }, 405)
94
+ let body
95
+ try {
96
+ body = await request.json()
97
+ } catch {
98
+ return json({ error: 'a JSON body is required' }, 400)
99
+ }
100
+ return handleFolders(body)
101
+ },
102
+ }))
103
+ },
104
+ }
105
+
106
+ /**
107
+ * Mount the folder route for one configuration.
108
+ *
109
+ * @param ctx - the row's context.
110
+ * @param config - the row's resolved configuration accessor.
111
+ * @returns the mounted plugin fiber.
112
+ */
113
+ export function mountFolders(ctx, config) {
114
+ return ctx.plugin({ ...foldersFeature, config })
115
+ }
@@ -24,6 +24,7 @@
24
24
  */
25
25
 
26
26
  import { mountCost } from './cost.js'
27
+ import { mountFolders } from './folders.js'
27
28
  import { mountGithub } from './github.js'
28
29
 
29
30
  /**
@@ -60,6 +61,13 @@ export const features = [
60
61
  defaultOn: true,
61
62
  mount: mountGithub,
62
63
  },
64
+ {
65
+ id: 'workspaceSources',
66
+ label: 'Add a workspace from this PC or GitHub',
67
+ description: 'Put an "On this PC" / "GitHub repository" source menu behind the Workspaces + button.',
68
+ defaultOn: true,
69
+ mount: mountFolders,
70
+ },
63
71
  ]
64
72
 
65
73
  /**
package/lib/client.js CHANGED
@@ -31,11 +31,17 @@ window.__ModuleLoader__.load({
31
31
  "label": "Add a workspace from GitHub",
32
32
  "description": "Sign in with the GitHub CLI, browse your repositories, and clone one as a workspace.",
33
33
  "defaultOn": true
34
+ },
35
+ {
36
+ "key": "workspaceSources",
37
+ "label": "Add a workspace from this PC or GitHub",
38
+ "description": "Put an On this PC / GitHub repository source menu behind the Workspaces + button.",
39
+ "defaultOn": true
34
40
  }
35
41
  ]
36
42
  const ROW_ID = "polish"
37
43
  const ROW_KEY = "@aryacode/dsh-aryacode-polish" + '#' + ROW_ID
38
- const CLIENT_VERSION = "0.5.0"
44
+ const CLIENT_VERSION = "0.6.2"
39
45
 
40
46
  /**
41
47
  * The two pure decisions behind the balance line: which wallet to show, and how
@@ -413,6 +419,24 @@ window.__ModuleLoader__.load({
413
419
  /** The label half of the pill, cut with an ellipsis rather than wrapped. */
414
420
  const PILL_LABEL_STYLE = { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }
415
421
 
422
+ /**
423
+ * The menu skin of the harness, made opaque on purpose.
424
+ *
425
+ * `--dsw-specific-menu` is a translucent fill (`#f8f9fa94`) that only reads as a
426
+ * solid surface because the harness pairs it with `backdrop-filter`. A plugin
427
+ * face has to portal its panels to `document.body`, and in the AryaCode desktop
428
+ * build that blur does not composite: the panel then showed the page behind it
429
+ * through a 58% fill. So the token stays as a tint over an opaque backdrop
430
+ * colour, and the blur is kept only where the platform actually honours it.
431
+ */
432
+ const PANEL_SURFACE = {
433
+ backgroundColor: 'Canvas',
434
+ backgroundImage:
435
+ 'linear-gradient(var(--dsw-specific-menu, rgba(248, 249, 250, 0.94)), var(--dsw-specific-menu, rgba(248, 249, 250, 0.94)))',
436
+ backdropFilter: 'var(--dsw-menu-backdrop-filter, none)',
437
+ border: '0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12))',
438
+ }
439
+
416
440
  /** The dialog surface, copied from the harness `stat-dialog.module.css` skin. */
417
441
  const PANEL_STYLE = {
418
442
  position: 'fixed',
@@ -422,10 +446,8 @@ window.__ModuleLoader__.load({
422
446
  minWidth: 'min(300px, calc(100vw - 24px))',
423
447
  maxWidth: 'min(440px, calc(100vw - 24px))',
424
448
  padding: '16px',
425
- border: 0,
426
449
  borderRadius: 'var(--dsw-radius-lg, 12px)',
427
- background: 'var(--dsw-specific-menu, Canvas)',
428
- backdropFilter: 'var(--dsw-menu-backdrop-filter, none)',
450
+ ...PANEL_SURFACE,
429
451
  boxShadow: 'var(--dsw-elevation-prominent, 0 10px 30px rgba(0, 0, 0, 0.18))',
430
452
  fontSize: '12px',
431
453
  lineHeight: '18px',
@@ -627,6 +649,7 @@ window.__ModuleLoader__.load({
627
649
  */
628
650
  const BALANCE_RETRY_DELAYS = [1500, 5000, 15000, 30000]
629
651
 
652
+
630
653
  /**
631
654
  * The remaining-balance line in the sidebar foot.
632
655
  *
@@ -788,7 +811,12 @@ window.__ModuleLoader__.load({
788
811
  */
789
812
  const githubStore = (() => {
790
813
  let open = false
814
+ // Set when the dialog was opened from the add-workspace flow: the flow owns
815
+ // workspace creation through `onPicked`, so a clone hands it the path rather
816
+ // than calling the Workspace service itself.
817
+ let picker
791
818
  const listeners = new Set()
819
+ const publish = () => { for (const listener of listeners) listener() }
792
820
  return {
793
821
  isOpen: () => open,
794
822
  subscribe(listener) {
@@ -798,7 +826,21 @@ window.__ModuleLoader__.load({
798
826
  set(next) {
799
827
  if (open === next) return
800
828
  open = next
801
- for (const listener of listeners) listener()
829
+ if (!next) picker = undefined
830
+ publish()
831
+ },
832
+ /** Open the dialog, optionally on behalf of the add-workspace flow. */
833
+ open(onPicked) {
834
+ picker = onPicked
835
+ if (open) return
836
+ open = true
837
+ publish()
838
+ },
839
+ /** Take the flow's `onPicked` once, so a second clone cannot reuse it. */
840
+ takePicker() {
841
+ const current = picker
842
+ picker = undefined
843
+ return current
802
844
  },
803
845
  }
804
846
  })()
@@ -823,6 +865,28 @@ window.__ModuleLoader__.load({
823
865
  return payload.value
824
866
  }
825
867
 
868
+ /** The Host route the folder browser talks over. Document-relative, like every app route. */
869
+ const FOLDERS_ROUTE = 'api/polish.folders'
870
+
871
+ /**
872
+ * Post one action to the folder route.
873
+ * @param body - `{ action: 'roots' }` or `{ action: 'ls', path }`.
874
+ * @returns the route's listing, or throws with its own message.
875
+ */
876
+ async function foldersCall(body) {
877
+ const response = await fetch(FOLDERS_ROUTE, {
878
+ method: 'POST',
879
+ headers: { 'content-type': 'application/json' },
880
+ body: JSON.stringify(body),
881
+ })
882
+ const payload = await response.json().catch(() => undefined)
883
+ if (payload === undefined || typeof payload !== 'object') {
884
+ throw new Error(`the Host answered ${String(response.status)}`)
885
+ }
886
+ if (payload.error !== undefined) throw new Error(String(payload.error))
887
+ return payload
888
+ }
889
+
826
890
  /** Modal backdrop and panel skin, matching the Shell's own dialogs. */
827
891
  const GITHUB_BACKDROP_STYLE = {
828
892
  position: 'fixed',
@@ -946,7 +1010,15 @@ window.__ModuleLoader__.load({
946
1010
  setError('')
947
1011
  Promise.resolve()
948
1012
  .then(() => githubCall('clone', { owner: repo.owner, name: repo.name }))
949
- .then(result => Promise.resolve(props.createWorkspace(result.path)).then(() => result))
1013
+ // The add-workspace flow owns creation when it opened this dialog; a
1014
+ // dialog opened from the footer creates the workspace itself.
1015
+ .then(result => {
1016
+ const onPicked = githubStore.takePicker()
1017
+ const land = onPicked === undefined
1018
+ ? Promise.resolve(props.createWorkspace(result.path))
1019
+ : Promise.resolve(onPicked(result.path))
1020
+ return land.then(() => result)
1021
+ })
950
1022
  .then(result => {
951
1023
  setBusy('')
952
1024
  githubStore.set(false)
@@ -1023,59 +1095,326 @@ window.__ModuleLoader__.load({
1023
1095
  , document.body)
1024
1096
  }
1025
1097
 
1098
+ /** The compact footer-row skin shared by the status line. */
1099
+ const FOOT_ROW_STYLE = {
1100
+ display: 'flex',
1101
+ alignItems: 'center',
1102
+ gap: '6px',
1103
+ maxWidth: '100%',
1104
+ minWidth: 0,
1105
+ padding: '2px 6px',
1106
+ border: 'none',
1107
+ borderRadius: 'var(--dsw-radius-sm, 6px)',
1108
+ background: 'transparent',
1109
+ color: 'var(--dsw-alias-label-tertiary, inherit)',
1110
+ font: 'inherit',
1111
+ fontSize: '12px',
1112
+ lineHeight: '18px',
1113
+ fontVariantNumeric: 'tabular-nums',
1114
+ whiteSpace: 'nowrap',
1115
+ overflow: 'hidden',
1116
+ textOverflow: 'ellipsis',
1117
+ cursor: 'pointer',
1118
+ }
1119
+
1026
1120
  /**
1027
- * The footer row that opens the GitHub dialog.
1121
+ * The footer status line: which GitHub session the "+" menu will clone with.
1122
+ *
1123
+ * It reads the same `status` action the dialog does, so it costs one request
1124
+ * when the Shell loads and one whenever the window regains focus.
1125
+ *
1028
1126
  * @param props - our lazy `configForms`.
1029
1127
  * @returns the row, or null when the feature is switched off.
1030
1128
  */
1031
- function GithubEntry(props) {
1129
+ function GithubStatus(props) {
1032
1130
  const enabled = useSwitch(props.configForms, 'githubWorkspace', true)
1131
+ const [status, setStatus] = React.useState(undefined)
1132
+ React.useEffect(() => {
1133
+ if (!enabled) return undefined
1134
+ let live = true
1135
+ const read = () => {
1136
+ Promise.resolve()
1137
+ .then(() => githubCall('status'))
1138
+ .then(next => { if (live) setStatus(next) })
1139
+ .catch(() => { if (live) setStatus({ signedIn: false }) })
1140
+ }
1141
+ read()
1142
+ const onFocus = () => { read() }
1143
+ window.addEventListener('focus', onFocus)
1144
+ return () => {
1145
+ live = false
1146
+ window.removeEventListener('focus', onFocus)
1147
+ }
1148
+ }, [enabled])
1033
1149
  if (!enabled) return null
1034
- return React.createElement('div', { style: { padding: '2px 8px 4px', minWidth: 0 } },
1150
+ const signedIn = status !== undefined && status.signedIn === true
1151
+ const label = status === undefined
1152
+ ? 'GitHub · checking…'
1153
+ : signedIn ? `GitHub · ${String(status.login)}` : 'GitHub · signed out'
1154
+ return React.createElement('div', { style: { padding: '2px 8px 0', minWidth: 0 } },
1035
1155
  React.createElement('button', {
1036
1156
  type: 'button',
1037
- 'data-polish-github': 'entry',
1038
- title: 'Add a workspace from GitHub',
1039
- 'aria-label': 'Add a workspace from GitHub',
1040
- onClick: () => githubStore.set(true),
1157
+ 'data-polish-github': 'status',
1158
+ 'data-polish-github-state': signedIn ? 'in' : status === undefined ? 'checking' : 'out',
1159
+ title: signedIn
1160
+ ? 'Add a workspace from GitHub'
1161
+ : 'Sign in with the GitHub CLI (gh auth login), then add a repository as a workspace',
1162
+ 'aria-label': label,
1163
+ onClick: () => githubStore.open(),
1164
+ style: FOOT_ROW_STYLE,
1165
+ onMouseEnter: event => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
1166
+ onMouseLeave: event => { event.currentTarget.style.background = 'transparent' },
1167
+ }, label))
1168
+ }
1169
+
1170
+ /** One row of the add-workspace source menu. */
1171
+ const SOURCE_ROW_STYLE = {
1172
+ display: 'flex',
1173
+ flexDirection: 'column',
1174
+ alignItems: 'flex-start',
1175
+ gap: '2px',
1176
+ width: '100%',
1177
+ padding: '8px 10px',
1178
+ border: 'none',
1179
+ borderRadius: 'var(--dsw-radius-sm, 6px)',
1180
+ background: 'transparent',
1181
+ color: 'inherit',
1182
+ font: 'inherit',
1183
+ textAlign: 'left',
1184
+ cursor: 'pointer',
1185
+ }
1186
+
1187
+ /**
1188
+ * The "+" add-workspace flow: choose where the workspace comes from.
1189
+ *
1190
+ * The Harness shows its own `Add workspace…` row only while this slot is
1191
+ * occupied, and a single-owner slot means this dialog stands in for the folder
1192
+ * picker. It therefore offers both sources: the machine (the Host's own OS
1193
+ * chooser when the composition serves the `native` capability, otherwise a
1194
+ * folder list driven by the browse primitives) and GitHub.
1195
+ *
1196
+ * @param props - the slot owner's `open`/`busy`/`onPicked`/`onCancel`/`onError`,
1197
+ * our lazy `configForms`, and the directory-picker calls.
1198
+ * @returns the flow, or null while it is closed or switched off.
1199
+ */
1200
+ function WorkspaceSourceFlow(props) {
1201
+ const enabled = useSwitch(props.configForms, 'workspaceSources', true)
1202
+ const [step, setStep] = React.useState('menu')
1203
+ const [listing, setListing] = React.useState(undefined)
1204
+ const [error, setError] = React.useState('')
1205
+ const [busy, setBusy] = React.useState(false)
1206
+
1207
+ const cancel = () => {
1208
+ if (typeof props.onCancel === 'function') props.onCancel()
1209
+ }
1210
+
1211
+ // Escape closes the flow. Declared with the other hooks, before the early
1212
+ // return, so the hook order stays fixed while the flow opens and closes.
1213
+ React.useEffect(() => {
1214
+ if (!enabled || props.open !== true) return undefined
1215
+ const onKey = event => { if (event.key === 'Escape') cancel() }
1216
+ document.addEventListener('keydown', onKey)
1217
+ return () => document.removeEventListener('keydown', onKey)
1218
+ }, [enabled, props.open])
1219
+
1220
+ if (!enabled || props.open !== true) return null
1221
+
1222
+ const fail = message => {
1223
+ setBusy(false)
1224
+ setError(message)
1225
+ }
1226
+ const messageOf = reason => (reason instanceof Error ? reason.message : String(reason))
1227
+
1228
+ const land = path => {
1229
+ setBusy(true)
1230
+ setError('')
1231
+ Promise.resolve()
1232
+ .then(() => props.onPicked(path))
1233
+ .catch(reason => fail(messageOf(reason)))
1234
+ }
1235
+
1236
+ const browse = path => {
1237
+ setBusy(true)
1238
+ setError('')
1239
+ Promise.resolve()
1240
+ .then(() => foldersCall({ action: 'ls', path }))
1241
+ .then(level => {
1242
+ setListing(level)
1243
+ setBusy(false)
1244
+ setStep('browse')
1245
+ })
1246
+ .catch(reason => fail(messageOf(reason)))
1247
+ }
1248
+
1249
+ /**
1250
+ * Hand folder choice to the Host.
1251
+ *
1252
+ * In the desktop app the preload exposes the real OS chooser as
1253
+ * `__DSH_DIRECTORY_PICKER__.pick()` — the same bridge the shipped native
1254
+ * picker uses — so the flow opens the Windows folder dialog and behaves like
1255
+ * the stock picker it stands in for. In a browser there is no such bridge, and
1256
+ * the composed picker's `pick` waits on a dialog that cannot open, so the flow
1257
+ * lists folders itself instead of hanging.
1258
+ */
1259
+ const chooseOnPc = () => {
1260
+ if (busy) return
1261
+ const bridge = typeof globalThis === 'undefined' ? undefined : globalThis.__DSH_DIRECTORY_PICKER__
1262
+ if (bridge !== undefined && typeof bridge.pick === 'function') {
1263
+ setBusy(true)
1264
+ setError('')
1265
+ Promise.resolve()
1266
+ .then(() => bridge.pick())
1267
+ .then(path => {
1268
+ if (typeof path === 'string' && path !== '') {
1269
+ land(path)
1270
+ return
1271
+ }
1272
+ setBusy(false)
1273
+ })
1274
+ .catch(reason => fail(messageOf(reason)))
1275
+ return
1276
+ }
1277
+ setBusy(true)
1278
+ setError('')
1279
+ Promise.resolve()
1280
+ .then(() => foldersCall({ action: 'roots' }))
1281
+ .then(payload => {
1282
+ const roots = Array.isArray(payload.roots) ? payload.roots : []
1283
+ setListing({
1284
+ path: '',
1285
+ parent: null,
1286
+ entries: roots.map(root => ({ name: root.label, path: root.path })),
1287
+ })
1288
+ setBusy(false)
1289
+ setStep('browse')
1290
+ })
1291
+ .catch(reason => fail(messageOf(reason)))
1292
+ }
1293
+
1294
+ const openGithub = () => {
1295
+ githubStore.open(path => props.onPicked(path))
1296
+ cancel()
1297
+ }
1298
+
1299
+ const rows = step === 'menu'
1300
+ ? [
1301
+ React.createElement('button', {
1302
+ key: 'pc',
1303
+ type: 'button',
1304
+ 'data-polish-source': 'machine',
1305
+ disabled: busy,
1306
+ onClick: chooseOnPc,
1307
+ style: SOURCE_ROW_STYLE,
1308
+ onMouseEnter: event => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
1309
+ onMouseLeave: event => { event.currentTarget.style.background = 'transparent' },
1310
+ },
1311
+ React.createElement('span', { style: { fontWeight: 500 } }, 'On this PC'),
1312
+ React.createElement('span', { style: { opacity: 0.7 } }, 'Choose a folder on this machine')),
1313
+ React.createElement('button', {
1314
+ key: 'github',
1315
+ type: 'button',
1316
+ 'data-polish-source': 'github',
1317
+ disabled: busy,
1318
+ onClick: openGithub,
1319
+ style: SOURCE_ROW_STYLE,
1320
+ onMouseEnter: event => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
1321
+ onMouseLeave: event => { event.currentTarget.style.background = 'transparent' },
1322
+ },
1323
+ React.createElement('span', { style: { fontWeight: 500 } }, 'GitHub repository…'),
1324
+ React.createElement('span', { style: { opacity: 0.7 } }, 'Clone one of your repositories as a workspace')),
1325
+ ]
1326
+ : [
1327
+ React.createElement('div', {
1328
+ key: 'head',
1329
+ style: { display: 'flex', alignItems: 'center', gap: '8px', padding: '2px 4px 6px', minWidth: 0 },
1330
+ },
1331
+ React.createElement('span', {
1332
+ style: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
1333
+ title: listing !== undefined ? listing.path : '',
1334
+ }, listing === undefined || listing.path === '' ? 'This PC' : listing.path),
1335
+ listing === undefined || listing.path === ''
1336
+ ? null
1337
+ : React.createElement('button', {
1338
+ type: 'button',
1339
+ 'data-polish-pick': 'here',
1340
+ disabled: busy,
1341
+ onClick: () => { if (listing !== undefined) land(listing.path) },
1342
+ style: { ...FOOT_ROW_STYLE, color: 'var(--dsw-alias-label-primary, inherit)', background: 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
1343
+ }, 'Use this folder')),
1344
+ ...(listing === undefined || listing.parent === null || listing.parent === undefined
1345
+ ? []
1346
+ : [React.createElement('button', {
1347
+ key: 'up',
1348
+ type: 'button',
1349
+ 'data-polish-folder': '..',
1350
+ disabled: busy,
1351
+ onClick: () => browse(listing.parent),
1352
+ style: SOURCE_ROW_STYLE,
1353
+ onMouseEnter: event => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
1354
+ onMouseLeave: event => { event.currentTarget.style.background = 'transparent' },
1355
+ }, React.createElement('span', { title: listing.parent }, '..'))]),
1356
+ ...(listing === undefined
1357
+ ? []
1358
+ : listing.entries.map(entry => React.createElement('button', {
1359
+ key: entry.path,
1360
+ type: 'button',
1361
+ 'data-polish-folder': entry.name,
1362
+ disabled: busy,
1363
+ onClick: () => browse(entry.path),
1364
+ style: SOURCE_ROW_STYLE,
1365
+ onMouseEnter: event => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
1366
+ onMouseLeave: event => { event.currentTarget.style.background = 'transparent' },
1367
+ }, React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title: entry.path }, entry.name)))),
1368
+ ]
1369
+
1370
+ // The flow is rendered inside the Shell's add-workspace popover, which is
1371
+ // narrow and sits under the sidebar's own stacking order. A portal to the body
1372
+ // puts it above everything and keeps the rows readable at their own width.
1373
+ return ReactDOM.createPortal(
1374
+ React.createElement('div', {
1375
+ style: GITHUB_BACKDROP_STYLE,
1376
+ 'data-polish-flow-backdrop': step,
1377
+ onPointerDown: event => { if (event.target === event.currentTarget) cancel() },
1378
+ },
1379
+ React.createElement('div', {
1380
+ 'data-polish-flow': step,
1381
+ role: 'dialog',
1382
+ 'aria-label': 'Add a workspace',
1041
1383
  style: {
1042
1384
  display: 'flex',
1043
- alignItems: 'center',
1044
- gap: '6px',
1045
- maxWidth: '100%',
1046
- minWidth: 0,
1047
- padding: '2px 6px',
1048
- border: 'none',
1049
- borderRadius: 'var(--dsw-radius-sm, 6px)',
1050
- background: 'transparent',
1051
- color: 'var(--dsw-alias-label-tertiary, inherit)',
1052
- font: 'inherit',
1385
+ flexDirection: 'column',
1386
+ gap: '2px',
1387
+ width: 'min(360px, calc(100vw - 32px))',
1388
+ maxHeight: 'min(420px, calc(100vh - 96px))',
1389
+ padding: '6px',
1390
+ overflowY: 'auto',
1391
+ borderRadius: 'var(--dsw-radius-lg, 12px)',
1392
+ ...PANEL_SURFACE,
1393
+ boxShadow: 'var(--dsw-elevation-prominent, 0 8px 24px rgba(0,0,0,0.18))',
1053
1394
  fontSize: '12px',
1054
1395
  lineHeight: '18px',
1055
- fontVariantNumeric: 'tabular-nums',
1056
- whiteSpace: 'nowrap',
1057
- overflow: 'hidden',
1058
- textOverflow: 'ellipsis',
1059
- cursor: 'pointer',
1396
+ color: 'var(--dsw-alias-label-secondary, inherit)',
1060
1397
  },
1061
- onMouseEnter: event => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
1062
- onMouseLeave: event => { event.currentTarget.style.background = 'transparent' },
1063
- },
1064
- React.createElement('svg', {
1065
- width: 14,
1066
- height: 14,
1067
- viewBox: '0 0 16 16',
1068
- fill: 'none',
1069
- stroke: 'currentColor',
1070
- strokeWidth: 1.2,
1071
- strokeLinecap: 'round',
1072
- strokeLinejoin: 'round',
1073
- 'aria-hidden': 'true',
1074
- style: { flex: 'none' },
1075
1398
  },
1076
- React.createElement('path', { d: 'M2.5 5.5h4l1.2 1.5h5.8v6.5h-11z' }),
1077
- React.createElement('path', { d: 'M8 9.8v3.4M6.3 11.5h3.4' })),
1078
- 'Add from GitHub'))
1399
+ error !== ''
1400
+ ? React.createElement('div', { style: { padding: '4px 6px', color: 'var(--dsw-alias-label-error, #b42318)' } }, error)
1401
+ : null,
1402
+ ...rows,
1403
+ step === 'menu'
1404
+ ? null
1405
+ : React.createElement('button', {
1406
+ type: 'button',
1407
+ 'data-polish-pick': 'back',
1408
+ onClick: () => { setStep('menu'); setListing(undefined); setError('') },
1409
+ style: { ...FOOT_ROW_STYLE, justifyContent: 'flex-start' },
1410
+ }, 'Back'),
1411
+ React.createElement('button', {
1412
+ type: 'button',
1413
+ 'data-polish-pick': 'cancel',
1414
+ onClick: cancel,
1415
+ style: { ...FOOT_ROW_STYLE, justifyContent: 'flex-start' },
1416
+ }, 'Cancel'))),
1417
+ document.body)
1079
1418
  }
1080
1419
 
1081
1420
  return {
@@ -1156,13 +1495,30 @@ window.__ModuleLoader__.load({
1156
1495
  id: 'polish-github',
1157
1496
  inject: () => ({ configForms, createWorkspace }),
1158
1497
  }, GitHubPanel))
1498
+ // The footer keeps the sign-in state, not an add button: adding a
1499
+ // workspace belongs in the "+" menu, where the Shell says it does.
1159
1500
  githubCtx.slots.inject('sidebar.footer.action', () => githubCtx.slots.register({
1160
1501
  name: 'sidebar.footer.action',
1161
- id: 'polish-github-entry',
1502
+ id: 'polish-github-status',
1162
1503
  order: 4,
1163
1504
  inject: () => ({ configForms }),
1164
- }, GithubEntry))
1505
+ }, GithubStatus))
1165
1506
  })
1507
+ // The "+" menu's `Add workspace…` row exists only while a plugin occupies
1508
+ // this single-owner hole, so occupying it means standing in for the folder
1509
+ // picker: the flow offers the machine and GitHub as sources. It needs no
1510
+ // Remote injection: the folder list and the clone both come from this
1511
+ // bundle's own Host routes, which is what makes the flow behave the same in
1512
+ // the browser and in the app.
1513
+ ctx.slots.inject('sidebar.workspaces.directoryFlow', () => ctx.slots.register({
1514
+ name: 'sidebar.workspaces.directoryFlow',
1515
+ // A single slot clashes only at the exact priority; another priority
1516
+ // shadows it and the LOWEST priority renders. The shipped pickers
1517
+ // register at 0, so -1 is what puts this flow in front of them without
1518
+ // making their own activation throw.
1519
+ priority: -1,
1520
+ inject: () => ({ configForms }),
1521
+ }, WorkspaceSourceFlow))
1166
1522
  },
1167
1523
  }
1168
1524
  },