@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.
- package/CHANGELOG.md +51 -0
- package/README.md +3 -2
- package/client/face.js +396 -46
- package/client/switches.json +6 -0
- package/docs/github-workspaces.md +31 -10
- package/entries/folders.js +115 -0
- package/entries/registry.js +8 -0
- package/lib/client.js +403 -47
- package/package.json +2 -2
|
@@ -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
|
+
}
|
package/entries/registry.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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': '
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
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
|
-
|
|
1044
|
-
gap: '
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
padding: '
|
|
1048
|
-
|
|
1049
|
-
borderRadius: 'var(--dsw-radius-
|
|
1050
|
-
|
|
1051
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
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-
|
|
1502
|
+
id: 'polish-github-status',
|
|
1162
1503
|
order: 4,
|
|
1163
1504
|
inject: () => ({ configForms }),
|
|
1164
|
-
},
|
|
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
|
},
|