@aryacode/dsh-aryacode-polish 0.4.4 → 0.6.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/lib/client.js CHANGED
@@ -25,11 +25,23 @@ window.__ModuleLoader__.load({
25
25
  "label": "Open workspace folder",
26
26
  "description": "Add an “Open workspace folder” row to a Session’s menu that opens its workspace in the file browser.",
27
27
  "defaultOn": true
28
+ },
29
+ {
30
+ "key": "githubWorkspace",
31
+ "label": "Add a workspace from GitHub",
32
+ "description": "Sign in with the GitHub CLI, browse your repositories, and clone one as a workspace.",
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
28
40
  }
29
41
  ]
30
42
  const ROW_ID = "polish"
31
43
  const ROW_KEY = "@aryacode/dsh-aryacode-polish" + '#' + ROW_ID
32
- const CLIENT_VERSION = "0.4.4"
44
+ const CLIENT_VERSION = "0.6.0"
33
45
 
34
46
  /**
35
47
  * The two pure decisions behind the balance line: which wallet to show, and how
@@ -621,6 +633,7 @@ window.__ModuleLoader__.load({
621
633
  */
622
634
  const BALANCE_RETRY_DELAYS = [1500, 5000, 15000, 30000]
623
635
 
636
+
624
637
  /**
625
638
  * The remaining-balance line in the sidebar foot.
626
639
  *
@@ -685,22 +698,24 @@ window.__ModuleLoader__.load({
685
698
  return React.createElement('div', { 'data-polish-balance': 'empty', style: { display: 'none' } })
686
699
  }
687
700
  const money = formatBalance(wallet)
701
+ // The foot is a single row shared with the GitHub entry, so the amount stays
702
+ // bare: a tooltip names it, and nothing here competes for width.
688
703
  return React.createElement('div', {
689
704
  title: 'Remaining account balance',
705
+ 'data-polish-balance': 'ready',
690
706
  style: {
691
707
  display: 'flex',
692
708
  alignItems: 'center',
693
709
  gap: '6px',
694
- padding: '2px 8px 4px',
710
+ padding: '2px 4px 4px 8px',
695
711
  fontSize: '12px',
696
712
  lineHeight: '16px',
697
- minWidth: 0,
698
- overflow: 'hidden',
713
+ flex: '0 0 auto',
699
714
  whiteSpace: 'nowrap',
700
- textOverflow: 'ellipsis',
701
715
  opacity: 0.75,
716
+ fontVariantNumeric: 'tabular-nums',
702
717
  },
703
- }, props.wide === false ? money : 'Balance ' + money)
718
+ }, money)
704
719
  }
705
720
 
706
721
  /**
@@ -771,6 +786,604 @@ window.__ModuleLoader__.load({
771
786
  return React.createElement('button', { type: 'button', role: 'menuitem', onClick: onSelect }, label)
772
787
  }
773
788
 
789
+ /** The Host route the GitHub halves talk over. Document-relative, like every app route. */
790
+ const GITHUB_ROUTE = 'api/polish.github'
791
+
792
+ /**
793
+ * Whether the GitHub panel is open. One store for the whole bundle: the footer
794
+ * row opens it, and the overlay panel is the only reader.
795
+ */
796
+ const githubStore = (() => {
797
+ let open = false
798
+ // Set when the dialog was opened from the add-workspace flow: the flow owns
799
+ // workspace creation through `onPicked`, so a clone hands it the path rather
800
+ // than calling the Workspace service itself.
801
+ let picker
802
+ const listeners = new Set()
803
+ const publish = () => { for (const listener of listeners) listener() }
804
+ return {
805
+ isOpen: () => open,
806
+ subscribe(listener) {
807
+ listeners.add(listener)
808
+ return () => { listeners.delete(listener) }
809
+ },
810
+ set(next) {
811
+ if (open === next) return
812
+ open = next
813
+ if (!next) picker = undefined
814
+ publish()
815
+ },
816
+ /** Open the dialog, optionally on behalf of the add-workspace flow. */
817
+ open(onPicked) {
818
+ picker = onPicked
819
+ if (open) return
820
+ open = true
821
+ publish()
822
+ },
823
+ /** Take the flow's `onPicked` once, so a second clone cannot reuse it. */
824
+ takePicker() {
825
+ const current = picker
826
+ picker = undefined
827
+ return current
828
+ },
829
+ }
830
+ })()
831
+
832
+ /**
833
+ * Post one action to the GitHub route.
834
+ * @param action - `status`, `repos` or `clone`.
835
+ * @param extra - fields the action needs.
836
+ * @returns the route's `value`, or throws with its own message.
837
+ */
838
+ async function githubCall(action, extra) {
839
+ const response = await fetch(GITHUB_ROUTE, {
840
+ method: 'POST',
841
+ headers: { 'content-type': 'application/json' },
842
+ body: JSON.stringify({ action, ...(extra ?? {}) }),
843
+ })
844
+ const payload = await response.json().catch(() => undefined)
845
+ if (payload === undefined || typeof payload !== 'object') {
846
+ throw new Error(`the Host answered ${String(response.status)}`)
847
+ }
848
+ if (payload.ok !== true) throw new Error(payload.error || 'GitHub request failed')
849
+ return payload.value
850
+ }
851
+
852
+ /** The Host route the folder browser talks over. Document-relative, like every app route. */
853
+ const FOLDERS_ROUTE = 'api/polish.folders'
854
+
855
+ /**
856
+ * Post one action to the folder route.
857
+ * @param body - `{ action: 'roots' }` or `{ action: 'ls', path }`.
858
+ * @returns the route's listing, or throws with its own message.
859
+ */
860
+ async function foldersCall(body) {
861
+ const response = await fetch(FOLDERS_ROUTE, {
862
+ method: 'POST',
863
+ headers: { 'content-type': 'application/json' },
864
+ body: JSON.stringify(body),
865
+ })
866
+ const payload = await response.json().catch(() => undefined)
867
+ if (payload === undefined || typeof payload !== 'object') {
868
+ throw new Error(`the Host answered ${String(response.status)}`)
869
+ }
870
+ if (payload.error !== undefined) throw new Error(String(payload.error))
871
+ return payload
872
+ }
873
+
874
+ /** Modal backdrop and panel skin, matching the Shell's own dialogs. */
875
+ const GITHUB_BACKDROP_STYLE = {
876
+ position: 'fixed',
877
+ inset: 0,
878
+ zIndex: 1200,
879
+ display: 'flex',
880
+ alignItems: 'center',
881
+ justifyContent: 'center',
882
+ background: 'rgba(16, 18, 22, 0.32)',
883
+ }
884
+
885
+ const GITHUB_CARD_STYLE = {
886
+ ...PANEL_STYLE,
887
+ position: 'static',
888
+ width: 'min(560px, calc(100vw - 32px))',
889
+ maxHeight: 'min(560px, calc(100vh - 64px))',
890
+ display: 'flex',
891
+ flexDirection: 'column',
892
+ overflow: 'hidden',
893
+ }
894
+
895
+ const GITHUB_LIST_STYLE = {
896
+ marginTop: '10px',
897
+ overflowY: 'auto',
898
+ borderTop: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.12))',
899
+ }
900
+
901
+ const GITHUB_ROW_STYLE = {
902
+ display: 'flex',
903
+ alignItems: 'center',
904
+ gap: '8px',
905
+ width: '100%',
906
+ padding: '8px 4px',
907
+ border: 'none',
908
+ background: 'transparent',
909
+ color: 'inherit',
910
+ font: 'inherit',
911
+ textAlign: 'left',
912
+ cursor: 'pointer',
913
+ borderBottom: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.08))',
914
+ }
915
+
916
+ const GITHUB_INPUT_STYLE = {
917
+ width: '100%',
918
+ boxSizing: 'border-box',
919
+ padding: '6px 8px',
920
+ font: 'inherit',
921
+ color: 'inherit',
922
+ background: 'transparent',
923
+ border: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.2))',
924
+ borderRadius: 'var(--dsw-radius-sm, 6px)',
925
+ }
926
+
927
+ /**
928
+ * The add-a-workspace-from-GitHub dialog.
929
+ *
930
+ * Sign-in is the GitHub CLI's own session: the Host asks `gh` for the token it
931
+ * already holds, so this dialog never collects a credential. A clone becomes a
932
+ * workspace through the Workspace Remote, which is the same call the "+" menu
933
+ * makes after its folder picker returns a path.
934
+ *
935
+ * @param props - our lazy `configForms` and the `createWorkspace` opener.
936
+ * @returns the dialog, or null while it is closed or switched off.
937
+ */
938
+ function GitHubPanel(props) {
939
+ const open = React.useSyncExternalStore(githubStore.subscribe, githubStore.isOpen)
940
+ const enabled = useSwitch(props.configForms, 'githubWorkspace', true)
941
+ const [phase, setPhase] = React.useState('idle')
942
+ const [account, setAccount] = React.useState(undefined)
943
+ const [repos, setRepos] = React.useState([])
944
+ const [query, setQuery] = React.useState('')
945
+ const [error, setError] = React.useState('')
946
+ const [busy, setBusy] = React.useState('')
947
+
948
+ React.useEffect(() => {
949
+ if (!open || !enabled) return undefined
950
+ let live = true
951
+ setPhase('loading')
952
+ setError('')
953
+ setBusy('')
954
+ setQuery('')
955
+ Promise.resolve()
956
+ .then(() => githubCall('status'))
957
+ .then(status => {
958
+ if (!live) return undefined
959
+ setAccount(status)
960
+ if (status.signedIn !== true) return undefined
961
+ return githubCall('repos').then(list => {
962
+ if (!live) return
963
+ setRepos(Array.isArray(list.repos) ? list.repos : [])
964
+ })
965
+ })
966
+ .then(() => { if (live) setPhase('ready') })
967
+ .catch(reason => {
968
+ if (!live) return
969
+ setError(reason instanceof Error ? reason.message : String(reason))
970
+ setPhase('ready')
971
+ })
972
+ return () => { live = false }
973
+ }, [open, enabled])
974
+
975
+ React.useEffect(() => {
976
+ if (!open) return undefined
977
+ const onKey = event => { if (event.key === 'Escape') githubStore.set(false) }
978
+ document.addEventListener('keydown', onKey)
979
+ return () => document.removeEventListener('keydown', onKey)
980
+ }, [open])
981
+
982
+ if (!open || !enabled) return null
983
+
984
+ const signedIn = account !== undefined && account.signedIn === true
985
+ const needle = query.trim().toLowerCase()
986
+ const visible = needle === ''
987
+ ? repos
988
+ : repos.filter(repo => `${repo.fullName} ${repo.description}`.toLowerCase().includes(needle))
989
+ const root = account !== undefined && typeof account.root === 'string' ? account.root : ''
990
+
991
+ const clone = repo => {
992
+ if (busy !== '') return
993
+ setBusy(repo.fullName)
994
+ setError('')
995
+ Promise.resolve()
996
+ .then(() => githubCall('clone', { owner: repo.owner, name: repo.name }))
997
+ // The add-workspace flow owns creation when it opened this dialog; a
998
+ // dialog opened from the footer creates the workspace itself.
999
+ .then(result => {
1000
+ const onPicked = githubStore.takePicker()
1001
+ const land = onPicked === undefined
1002
+ ? Promise.resolve(props.createWorkspace(result.path))
1003
+ : Promise.resolve(onPicked(result.path))
1004
+ return land.then(() => result)
1005
+ })
1006
+ .then(result => {
1007
+ setBusy('')
1008
+ githubStore.set(false)
1009
+ if (typeof console !== 'undefined' && typeof console.info === 'function') {
1010
+ console.info('[aryacode-polish] workspace ready:', result.path)
1011
+ }
1012
+ })
1013
+ .catch(reason => {
1014
+ setBusy('')
1015
+ setError(reason instanceof Error ? reason.message : String(reason))
1016
+ })
1017
+ }
1018
+
1019
+ const rows = visible.slice(0, 200).map(repo => React.createElement('button', {
1020
+ key: repo.fullName,
1021
+ type: 'button',
1022
+ style: GITHUB_ROW_STYLE,
1023
+ disabled: busy !== '',
1024
+ onClick: () => clone(repo),
1025
+ title: repo.description === '' ? repo.fullName : `${repo.fullName} — ${repo.description}`,
1026
+ 'data-polish-github-row': repo.fullName,
1027
+ },
1028
+ React.createElement('span', { style: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
1029
+ repo.fullName,
1030
+ repo.isPrivate ? React.createElement('span', { style: { marginLeft: '6px', opacity: 0.6 } }, 'private') : null,
1031
+ repo.isFork ? React.createElement('span', { style: { marginLeft: '6px', opacity: 0.6 } }, 'fork') : null),
1032
+ repo.cloned === true
1033
+ ? React.createElement('span', { style: { opacity: 0.6 } }, 'cloned')
1034
+ : null,
1035
+ busy === repo.fullName ? React.createElement('span', { style: { opacity: 0.7 } }, 'cloning…') : null))
1036
+
1037
+ return ReactDOM.createPortal(
1038
+ React.createElement('div', {
1039
+ style: GITHUB_BACKDROP_STYLE,
1040
+ 'data-polish-github': 'dialog',
1041
+ onPointerDown: event => { if (event.target === event.currentTarget) githubStore.set(false) },
1042
+ },
1043
+ React.createElement('div', { style: GITHUB_CARD_STYLE, role: 'dialog', 'aria-label': 'Add a workspace from GitHub' },
1044
+ React.createElement('div', { style: { ...PANEL_TITLE_STYLE, marginBottom: '4px' } },
1045
+ React.createElement('span', null, 'Add a workspace from GitHub'),
1046
+ React.createElement('button', {
1047
+ type: 'button',
1048
+ onClick: () => githubStore.set(false),
1049
+ style: { border: 'none', background: 'transparent', color: 'inherit', font: 'inherit', cursor: 'pointer', opacity: 0.7 },
1050
+ 'aria-label': 'Close',
1051
+ }, '×')),
1052
+ React.createElement('div', { style: { opacity: 0.75, minHeight: '18px' } },
1053
+ phase === 'loading' && !signedIn ? 'Checking the GitHub CLI session…' : null,
1054
+ signedIn ? `Signed in as ${String(account.login)} · cloning into ${root}` : null,
1055
+ !signedIn && phase !== 'loading'
1056
+ ? 'The GitHub CLI is not signed in. Run `gh auth login` in a terminal, then reopen this dialog.'
1057
+ : null),
1058
+ signedIn
1059
+ ? React.createElement('input', {
1060
+ style: { ...GITHUB_INPUT_STYLE, marginTop: '10px' },
1061
+ value: query,
1062
+ placeholder: 'Search your repositories',
1063
+ 'aria-label': 'Search your repositories',
1064
+ autoFocus: true,
1065
+ onChange: event => setQuery(event.target.value),
1066
+ })
1067
+ : null,
1068
+ error !== ''
1069
+ ? React.createElement('div', { style: { marginTop: '10px', color: 'var(--dsw-alias-label-error, #b42318)' } }, error)
1070
+ : null,
1071
+ signedIn
1072
+ ? React.createElement('div', { style: GITHUB_LIST_STYLE, 'data-polish-github-list': String(visible.length) },
1073
+ phase === 'loading' && visible.length === 0 ? React.createElement('div', { style: { padding: '10px 4px', opacity: 0.7 } }, 'Loading repositories…') : null,
1074
+ phase === 'ready' && visible.length === 0 ? React.createElement('div', { style: { padding: '10px 4px', opacity: 0.7 } }, 'No repository matches that search.') : null,
1075
+ ...rows)
1076
+ : null,
1077
+ React.createElement('p', { style: { margin: '10px 0 0', color: 'var(--dsw-alias-label-tertiary, inherit)' } },
1078
+ 'The clone is added as a workspace. Sign-in comes from the GitHub CLI, so this plugin stores no token.')))
1079
+ , document.body)
1080
+ }
1081
+
1082
+ /** The compact footer-row skin shared by the status line. */
1083
+ const FOOT_ROW_STYLE = {
1084
+ display: 'flex',
1085
+ alignItems: 'center',
1086
+ gap: '6px',
1087
+ maxWidth: '100%',
1088
+ minWidth: 0,
1089
+ padding: '2px 6px',
1090
+ border: 'none',
1091
+ borderRadius: 'var(--dsw-radius-sm, 6px)',
1092
+ background: 'transparent',
1093
+ color: 'var(--dsw-alias-label-tertiary, inherit)',
1094
+ font: 'inherit',
1095
+ fontSize: '12px',
1096
+ lineHeight: '18px',
1097
+ fontVariantNumeric: 'tabular-nums',
1098
+ whiteSpace: 'nowrap',
1099
+ overflow: 'hidden',
1100
+ textOverflow: 'ellipsis',
1101
+ cursor: 'pointer',
1102
+ }
1103
+
1104
+ /**
1105
+ * The footer status line: which GitHub session the "+" menu will clone with.
1106
+ *
1107
+ * It reads the same `status` action the dialog does, so it costs one request
1108
+ * when the Shell loads and one whenever the window regains focus.
1109
+ *
1110
+ * @param props - our lazy `configForms`.
1111
+ * @returns the row, or null when the feature is switched off.
1112
+ */
1113
+ function GithubStatus(props) {
1114
+ const enabled = useSwitch(props.configForms, 'githubWorkspace', true)
1115
+ const [status, setStatus] = React.useState(undefined)
1116
+ React.useEffect(() => {
1117
+ if (!enabled) return undefined
1118
+ let live = true
1119
+ const read = () => {
1120
+ Promise.resolve()
1121
+ .then(() => githubCall('status'))
1122
+ .then(next => { if (live) setStatus(next) })
1123
+ .catch(() => { if (live) setStatus({ signedIn: false }) })
1124
+ }
1125
+ read()
1126
+ const onFocus = () => { read() }
1127
+ window.addEventListener('focus', onFocus)
1128
+ return () => {
1129
+ live = false
1130
+ window.removeEventListener('focus', onFocus)
1131
+ }
1132
+ }, [enabled])
1133
+ if (!enabled) return null
1134
+ const signedIn = status !== undefined && status.signedIn === true
1135
+ const label = status === undefined
1136
+ ? 'GitHub · checking…'
1137
+ : signedIn ? `GitHub · ${String(status.login)}` : 'GitHub · signed out'
1138
+ return React.createElement('div', { style: { padding: '2px 8px 0', minWidth: 0 } },
1139
+ React.createElement('button', {
1140
+ type: 'button',
1141
+ 'data-polish-github': 'status',
1142
+ 'data-polish-github-state': signedIn ? 'in' : status === undefined ? 'checking' : 'out',
1143
+ title: signedIn
1144
+ ? 'Add a workspace from GitHub'
1145
+ : 'Sign in with the GitHub CLI (gh auth login), then add a repository as a workspace',
1146
+ 'aria-label': label,
1147
+ onClick: () => githubStore.open(),
1148
+ style: FOOT_ROW_STYLE,
1149
+ onMouseEnter: event => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
1150
+ onMouseLeave: event => { event.currentTarget.style.background = 'transparent' },
1151
+ }, label))
1152
+ }
1153
+
1154
+ /** One row of the add-workspace source menu. */
1155
+ const SOURCE_ROW_STYLE = {
1156
+ display: 'flex',
1157
+ flexDirection: 'column',
1158
+ alignItems: 'flex-start',
1159
+ gap: '2px',
1160
+ width: '100%',
1161
+ padding: '8px 10px',
1162
+ border: 'none',
1163
+ borderRadius: 'var(--dsw-radius-sm, 6px)',
1164
+ background: 'transparent',
1165
+ color: 'inherit',
1166
+ font: 'inherit',
1167
+ textAlign: 'left',
1168
+ cursor: 'pointer',
1169
+ }
1170
+
1171
+ /**
1172
+ * The "+" add-workspace flow: choose where the workspace comes from.
1173
+ *
1174
+ * The Harness shows its own `Add workspace…` row only while this slot is
1175
+ * occupied, and a single-owner slot means this dialog stands in for the folder
1176
+ * picker. It therefore offers both sources: the machine (the Host's own OS
1177
+ * chooser when the composition serves the `native` capability, otherwise a
1178
+ * folder list driven by the browse primitives) and GitHub.
1179
+ *
1180
+ * @param props - the slot owner's `open`/`busy`/`onPicked`/`onCancel`/`onError`,
1181
+ * our lazy `configForms`, and the directory-picker calls.
1182
+ * @returns the flow, or null while it is closed or switched off.
1183
+ */
1184
+ function WorkspaceSourceFlow(props) {
1185
+ const enabled = useSwitch(props.configForms, 'workspaceSources', true)
1186
+ const [step, setStep] = React.useState('menu')
1187
+ const [listing, setListing] = React.useState(undefined)
1188
+ const [error, setError] = React.useState('')
1189
+ const [busy, setBusy] = React.useState(false)
1190
+
1191
+ const cancel = () => {
1192
+ if (typeof props.onCancel === 'function') props.onCancel()
1193
+ }
1194
+
1195
+ // Escape closes the flow. Declared with the other hooks, before the early
1196
+ // return, so the hook order stays fixed while the flow opens and closes.
1197
+ React.useEffect(() => {
1198
+ if (!enabled || props.open !== true) return undefined
1199
+ const onKey = event => { if (event.key === 'Escape') cancel() }
1200
+ document.addEventListener('keydown', onKey)
1201
+ return () => document.removeEventListener('keydown', onKey)
1202
+ }, [enabled, props.open])
1203
+
1204
+ if (!enabled || props.open !== true) return null
1205
+
1206
+ const fail = message => {
1207
+ setBusy(false)
1208
+ setError(message)
1209
+ }
1210
+ const messageOf = reason => (reason instanceof Error ? reason.message : String(reason))
1211
+
1212
+ const land = path => {
1213
+ setBusy(true)
1214
+ setError('')
1215
+ Promise.resolve()
1216
+ .then(() => props.onPicked(path))
1217
+ .catch(reason => fail(messageOf(reason)))
1218
+ }
1219
+
1220
+ const browse = path => {
1221
+ setBusy(true)
1222
+ setError('')
1223
+ Promise.resolve()
1224
+ .then(() => foldersCall({ action: 'ls', path }))
1225
+ .then(level => {
1226
+ setListing(level)
1227
+ setBusy(false)
1228
+ setStep('browse')
1229
+ })
1230
+ .catch(reason => fail(messageOf(reason)))
1231
+ }
1232
+
1233
+ /**
1234
+ * Start the folder browser at the machine's roots.
1235
+ *
1236
+ * This deliberately does not call the Host's directory picker: a composed
1237
+ * picker serves either `browse` or `native`, never both, and the browser build
1238
+ * composes the one whose chooser cannot open there — a `pick` call would wait
1239
+ * for a dialog that never appears. Our own listing behaves the same in the app
1240
+ * and in the browser, and hands the chosen path back through `onPicked`.
1241
+ */
1242
+ const chooseOnPc = () => {
1243
+ if (busy) return
1244
+ setBusy(true)
1245
+ setError('')
1246
+ Promise.resolve()
1247
+ .then(() => foldersCall({ action: 'roots' }))
1248
+ .then(payload => {
1249
+ const roots = Array.isArray(payload.roots) ? payload.roots : []
1250
+ setListing({
1251
+ path: '',
1252
+ parent: null,
1253
+ entries: roots.map(root => ({ name: root.label, path: root.path })),
1254
+ })
1255
+ setBusy(false)
1256
+ setStep('browse')
1257
+ })
1258
+ .catch(reason => fail(messageOf(reason)))
1259
+ }
1260
+
1261
+ const openGithub = () => {
1262
+ githubStore.open(path => props.onPicked(path))
1263
+ cancel()
1264
+ }
1265
+
1266
+ const rows = step === 'menu'
1267
+ ? [
1268
+ React.createElement('button', {
1269
+ key: 'pc',
1270
+ type: 'button',
1271
+ 'data-polish-source': 'machine',
1272
+ disabled: busy,
1273
+ onClick: chooseOnPc,
1274
+ style: SOURCE_ROW_STYLE,
1275
+ onMouseEnter: event => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
1276
+ onMouseLeave: event => { event.currentTarget.style.background = 'transparent' },
1277
+ },
1278
+ React.createElement('span', { style: { fontWeight: 500 } }, 'On this PC'),
1279
+ React.createElement('span', { style: { opacity: 0.7 } }, 'Choose a folder on this machine')),
1280
+ React.createElement('button', {
1281
+ key: 'github',
1282
+ type: 'button',
1283
+ 'data-polish-source': 'github',
1284
+ disabled: busy,
1285
+ onClick: openGithub,
1286
+ style: SOURCE_ROW_STYLE,
1287
+ onMouseEnter: event => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
1288
+ onMouseLeave: event => { event.currentTarget.style.background = 'transparent' },
1289
+ },
1290
+ React.createElement('span', { style: { fontWeight: 500 } }, 'GitHub repository…'),
1291
+ React.createElement('span', { style: { opacity: 0.7 } }, 'Clone one of your repositories as a workspace')),
1292
+ ]
1293
+ : [
1294
+ React.createElement('div', {
1295
+ key: 'head',
1296
+ style: { display: 'flex', alignItems: 'center', gap: '8px', padding: '2px 4px 6px', minWidth: 0 },
1297
+ },
1298
+ React.createElement('span', {
1299
+ style: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
1300
+ title: listing !== undefined ? listing.path : '',
1301
+ }, listing === undefined || listing.path === '' ? 'This PC' : listing.path),
1302
+ listing === undefined || listing.path === ''
1303
+ ? null
1304
+ : React.createElement('button', {
1305
+ type: 'button',
1306
+ 'data-polish-pick': 'here',
1307
+ disabled: busy,
1308
+ onClick: () => { if (listing !== undefined) land(listing.path) },
1309
+ style: { ...FOOT_ROW_STYLE, color: 'var(--dsw-alias-label-primary, inherit)', background: 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
1310
+ }, 'Use this folder')),
1311
+ ...(listing === undefined || listing.parent === null || listing.parent === undefined
1312
+ ? []
1313
+ : [React.createElement('button', {
1314
+ key: 'up',
1315
+ type: 'button',
1316
+ 'data-polish-folder': '..',
1317
+ disabled: busy,
1318
+ onClick: () => browse(listing.parent),
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
+ }, React.createElement('span', { title: listing.parent }, '..'))]),
1323
+ ...(listing === undefined
1324
+ ? []
1325
+ : listing.entries.map(entry => React.createElement('button', {
1326
+ key: entry.path,
1327
+ type: 'button',
1328
+ 'data-polish-folder': entry.name,
1329
+ disabled: busy,
1330
+ onClick: () => browse(entry.path),
1331
+ style: SOURCE_ROW_STYLE,
1332
+ onMouseEnter: event => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
1333
+ onMouseLeave: event => { event.currentTarget.style.background = 'transparent' },
1334
+ }, React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title: entry.path }, entry.name)))),
1335
+ ]
1336
+
1337
+ // The flow is rendered inside the Shell's add-workspace popover, which is
1338
+ // narrow and sits under the sidebar's own stacking order. A portal to the body
1339
+ // puts it above everything and keeps the rows readable at their own width.
1340
+ return ReactDOM.createPortal(
1341
+ React.createElement('div', {
1342
+ style: GITHUB_BACKDROP_STYLE,
1343
+ 'data-polish-flow-backdrop': step,
1344
+ onPointerDown: event => { if (event.target === event.currentTarget) cancel() },
1345
+ },
1346
+ React.createElement('div', {
1347
+ 'data-polish-flow': step,
1348
+ role: 'dialog',
1349
+ 'aria-label': 'Add a workspace',
1350
+ style: {
1351
+ display: 'flex',
1352
+ flexDirection: 'column',
1353
+ gap: '2px',
1354
+ width: 'min(360px, calc(100vw - 32px))',
1355
+ maxHeight: 'min(420px, calc(100vh - 96px))',
1356
+ padding: '6px',
1357
+ overflowY: 'auto',
1358
+ borderRadius: 'var(--dsw-radius-lg, 12px)',
1359
+ background: 'var(--dsw-specific-menu, Canvas)',
1360
+ boxShadow: 'var(--dsw-elevation-prominent, 0 8px 24px rgba(0,0,0,0.18))',
1361
+ fontSize: '12px',
1362
+ lineHeight: '18px',
1363
+ color: 'var(--dsw-alias-label-secondary, inherit)',
1364
+ },
1365
+ },
1366
+ error !== ''
1367
+ ? React.createElement('div', { style: { padding: '4px 6px', color: 'var(--dsw-alias-label-error, #b42318)' } }, error)
1368
+ : null,
1369
+ ...rows,
1370
+ step === 'menu'
1371
+ ? null
1372
+ : React.createElement('button', {
1373
+ type: 'button',
1374
+ 'data-polish-pick': 'back',
1375
+ onClick: () => { setStep('menu'); setListing(undefined); setError('') },
1376
+ style: { ...FOOT_ROW_STYLE, justifyContent: 'flex-start' },
1377
+ }, 'Back'),
1378
+ React.createElement('button', {
1379
+ type: 'button',
1380
+ 'data-polish-pick': 'cancel',
1381
+ onClick: cancel,
1382
+ style: { ...FOOT_ROW_STYLE, justifyContent: 'flex-start' },
1383
+ }, 'Cancel'))),
1384
+ document.body)
1385
+ }
1386
+
774
1387
  return {
775
1388
  inject: ['slots'],
776
1389
  /**
@@ -833,6 +1446,46 @@ window.__ModuleLoader__.load({
833
1446
  inject: () => ({ configForms, openFolder, workspaces: workspaceCtx.get('workspaces') }),
834
1447
  }, OpenFolderMenuItem))
835
1448
  })
1449
+ // Adding a workspace from GitHub goes through the Workspace service's own
1450
+ // create call — the same one the "+" menu makes once its folder picker
1451
+ // returns a path — so a clone lands in the sidebar exactly like a picked
1452
+ // folder does, with no Harness change anywhere. The service takes the plain
1453
+ // path and throws on refusal; the Remote namespace underneath it is
1454
+ // `ctx.remote.workspace` (singular), which is why this injects the service.
1455
+ ctx.inject(['workspaces'], (githubCtx) => {
1456
+ const createWorkspace = async (path) => {
1457
+ const workspace = await githubCtx.workspaces.create({ path })
1458
+ return workspace
1459
+ }
1460
+ githubCtx.slots.inject('shell.overlay', () => githubCtx.slots.register({
1461
+ name: 'shell.overlay',
1462
+ id: 'polish-github',
1463
+ inject: () => ({ configForms, createWorkspace }),
1464
+ }, GitHubPanel))
1465
+ // The footer keeps the sign-in state, not an add button: adding a
1466
+ // workspace belongs in the "+" menu, where the Shell says it does.
1467
+ githubCtx.slots.inject('sidebar.footer.action', () => githubCtx.slots.register({
1468
+ name: 'sidebar.footer.action',
1469
+ id: 'polish-github-status',
1470
+ order: 4,
1471
+ inject: () => ({ configForms }),
1472
+ }, GithubStatus))
1473
+ })
1474
+ // The "+" menu's `Add workspace…` row exists only while a plugin occupies
1475
+ // this single-owner hole, so occupying it means standing in for the folder
1476
+ // picker: the flow offers the machine and GitHub as sources. It needs no
1477
+ // Remote injection: the folder list and the clone both come from this
1478
+ // bundle's own Host routes, which is what makes the flow behave the same in
1479
+ // the browser and in the app.
1480
+ ctx.slots.inject('sidebar.workspaces.directoryFlow', () => ctx.slots.register({
1481
+ name: 'sidebar.workspaces.directoryFlow',
1482
+ // A single slot clashes only at the exact priority; another priority
1483
+ // shadows it and the LOWEST priority renders. The shipped pickers
1484
+ // register at 0, so -1 is what puts this flow in front of them without
1485
+ // making their own activation throw.
1486
+ priority: -1,
1487
+ inject: () => ({ configForms }),
1488
+ }, WorkspaceSourceFlow))
836
1489
  },
837
1490
  }
838
1491
  },