@aryacode/dsh-aryacode-polish 0.5.0 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.0] - 2026-10-28
4
+
5
+ ### Changed
6
+
7
+ - **Adding a workspace moved into the Workspaces "+" menu.** A new switch
8
+ (`workspaceSources`) occupies `sidebar.workspaces.directoryFlow`, which is what
9
+ makes the Shell's **Add workspace…** row appear at all, and stands in for the
10
+ folder picker with the source menu the owner asked for: **On this PC** (a folder
11
+ browser driven by this bundle's own `api/polish.folders` route) and **GitHub
12
+ repository…** (the existing dialog). A finished clone hands its path to the
13
+ flow's `onPicked`, so the Shell creates the workspace exactly as it does for a
14
+ picked folder.
15
+
16
+ ### Added
17
+
18
+ - The sidebar foot now carries the GitHub sign-in state next to the balance
19
+ (`GitHub · <login>` / `GitHub · signed out`); clicking it opens the repository
20
+ dialog. It replaced the foot's add-workspace row, which the "+" menu supersedes.
21
+
22
+ ### Fixed
23
+
24
+ - "On this PC" lists folders instead of waiting on a chooser that cannot open. A
25
+ composed directory picker serves one capability, `browse` or `native`, and the
26
+ browser build composes the one with no chooser to show, so the previous
27
+ `directoryPicker.pick()` call simply never settled. The flow now walks folders
28
+ through its own route: roots, a `..` row for going back, directories only, and
29
+ hidden or system entries filtered out.
30
+
3
31
  ## [0.5.0] - 2026-10-27
4
32
 
5
33
  ### Added
package/README.md CHANGED
@@ -7,7 +7,7 @@ installed into and patches nothing. It is one package, one Plugins row, and one
7
7
  switch per feature, so a refinement that turns out to be wrong for a machine can
8
8
  be turned off without uninstalling anything.
9
9
 
10
- Status: **four features shipped.** The version is the package's, and the card
10
+ Status: **five features shipped.** The version is the package's, and the card
11
11
  header already shows it.
12
12
 
13
13
  ## What it is
@@ -33,7 +33,8 @@ Configure page automatically.
33
33
  | **Estimated session cost** (`sessionCost`) | On | A `≈$0.39` pill in the composer dock that opens a breakdown card — cache hit, uncached input, cached input, output, requests, and the estimate — priced from the session's recorded usage at the published DeepSeek rates. |
34
34
  | **Account balance** (`accountBalance`) | On | The account's remaining wallet (`$17.33`) in the sidebar foot, above the account row. Renders nothing when the Host exposes no wallet, so it never shows a number nobody gave it. |
35
35
  | **Open workspace folder** (`openWorkspaceFolder`) | On | An **Open workspace folder** row in a Session's "…" menu that opens the Session's workspace in Explorer/Finder. Renders nothing when the Session has no workspace or the Host cannot open a path. See [Open a workspace folder](docs/workspace-folder.md) for why the request had to land here instead of on the workspace row itself. |
36
- | **Add a workspace from GitHub** (`githubWorkspace`) | On | **Add workspace from GitHub** in the sidebar foot: the dialog lists the repositories your GitHub CLI session can clone, and cloning one creates the workspace. The token never enters the browser — it is read on the Host, used only as git's http auth header, and never stored. See [GitHub workspaces](docs/github-workspaces.md). |
36
+ | **Add a workspace from GitHub** (`githubWorkspace`) | On | Adds the GitHub half of the workspace sources: the dialog lists the repositories your GitHub CLI session can clone, and cloning one creates the workspace. The token never enters the browser — it is read on the Host, used only as git's http auth header, and never stored. The sidebar foot carries the sign-in state (`GitHub · <login>`). See [GitHub workspaces](docs/github-workspaces.md). |
37
+ | **Add a workspace from this PC or GitHub** (`workspaceSources`) | On | The Workspaces **+** button gets a source menu: **On this PC** (the Host's OS chooser, or a folder list when the composition has no native chooser) and **GitHub repository…**. Off, the **+** button goes back to the shipped folder picker. |
37
38
 
38
39
  ### How the cost is computed
39
40
 
package/client/face.js CHANGED
@@ -466,6 +466,7 @@ function CostPill(props) {
466
466
  */
467
467
  const BALANCE_RETRY_DELAYS = [1500, 5000, 15000, 30000]
468
468
 
469
+
469
470
  /**
470
471
  * The remaining-balance line in the sidebar foot.
471
472
  *
@@ -627,7 +628,12 @@ const GITHUB_ROUTE = 'api/polish.github'
627
628
  */
628
629
  const githubStore = (() => {
629
630
  let open = false
631
+ // Set when the dialog was opened from the add-workspace flow: the flow owns
632
+ // workspace creation through `onPicked`, so a clone hands it the path rather
633
+ // than calling the Workspace service itself.
634
+ let picker
630
635
  const listeners = new Set()
636
+ const publish = () => { for (const listener of listeners) listener() }
631
637
  return {
632
638
  isOpen: () => open,
633
639
  subscribe(listener) {
@@ -637,7 +643,21 @@ const githubStore = (() => {
637
643
  set(next) {
638
644
  if (open === next) return
639
645
  open = next
640
- for (const listener of listeners) listener()
646
+ if (!next) picker = undefined
647
+ publish()
648
+ },
649
+ /** Open the dialog, optionally on behalf of the add-workspace flow. */
650
+ open(onPicked) {
651
+ picker = onPicked
652
+ if (open) return
653
+ open = true
654
+ publish()
655
+ },
656
+ /** Take the flow's `onPicked` once, so a second clone cannot reuse it. */
657
+ takePicker() {
658
+ const current = picker
659
+ picker = undefined
660
+ return current
641
661
  },
642
662
  }
643
663
  })()
@@ -662,6 +682,28 @@ async function githubCall(action, extra) {
662
682
  return payload.value
663
683
  }
664
684
 
685
+ /** The Host route the folder browser talks over. Document-relative, like every app route. */
686
+ const FOLDERS_ROUTE = 'api/polish.folders'
687
+
688
+ /**
689
+ * Post one action to the folder route.
690
+ * @param body - `{ action: 'roots' }` or `{ action: 'ls', path }`.
691
+ * @returns the route's listing, or throws with its own message.
692
+ */
693
+ async function foldersCall(body) {
694
+ const response = await fetch(FOLDERS_ROUTE, {
695
+ method: 'POST',
696
+ headers: { 'content-type': 'application/json' },
697
+ body: JSON.stringify(body),
698
+ })
699
+ const payload = await response.json().catch(() => undefined)
700
+ if (payload === undefined || typeof payload !== 'object') {
701
+ throw new Error(`the Host answered ${String(response.status)}`)
702
+ }
703
+ if (payload.error !== undefined) throw new Error(String(payload.error))
704
+ return payload
705
+ }
706
+
665
707
  /** Modal backdrop and panel skin, matching the Shell's own dialogs. */
666
708
  const GITHUB_BACKDROP_STYLE = {
667
709
  position: 'fixed',
@@ -785,7 +827,15 @@ function GitHubPanel(props) {
785
827
  setError('')
786
828
  Promise.resolve()
787
829
  .then(() => githubCall('clone', { owner: repo.owner, name: repo.name }))
788
- .then(result => Promise.resolve(props.createWorkspace(result.path)).then(() => result))
830
+ // The add-workspace flow owns creation when it opened this dialog; a
831
+ // dialog opened from the footer creates the workspace itself.
832
+ .then(result => {
833
+ const onPicked = githubStore.takePicker()
834
+ const land = onPicked === undefined
835
+ ? Promise.resolve(props.createWorkspace(result.path))
836
+ : Promise.resolve(onPicked(result.path))
837
+ return land.then(() => result)
838
+ })
789
839
  .then(result => {
790
840
  setBusy('')
791
841
  githubStore.set(false)
@@ -862,59 +912,309 @@ function GitHubPanel(props) {
862
912
  , document.body)
863
913
  }
864
914
 
915
+ /** The compact footer-row skin shared by the status line. */
916
+ const FOOT_ROW_STYLE = {
917
+ display: 'flex',
918
+ alignItems: 'center',
919
+ gap: '6px',
920
+ maxWidth: '100%',
921
+ minWidth: 0,
922
+ padding: '2px 6px',
923
+ border: 'none',
924
+ borderRadius: 'var(--dsw-radius-sm, 6px)',
925
+ background: 'transparent',
926
+ color: 'var(--dsw-alias-label-tertiary, inherit)',
927
+ font: 'inherit',
928
+ fontSize: '12px',
929
+ lineHeight: '18px',
930
+ fontVariantNumeric: 'tabular-nums',
931
+ whiteSpace: 'nowrap',
932
+ overflow: 'hidden',
933
+ textOverflow: 'ellipsis',
934
+ cursor: 'pointer',
935
+ }
936
+
865
937
  /**
866
- * The footer row that opens the GitHub dialog.
938
+ * The footer status line: which GitHub session the "+" menu will clone with.
939
+ *
940
+ * It reads the same `status` action the dialog does, so it costs one request
941
+ * when the Shell loads and one whenever the window regains focus.
942
+ *
867
943
  * @param props - our lazy `configForms`.
868
944
  * @returns the row, or null when the feature is switched off.
869
945
  */
870
- function GithubEntry(props) {
946
+ function GithubStatus(props) {
871
947
  const enabled = useSwitch(props.configForms, 'githubWorkspace', true)
948
+ const [status, setStatus] = React.useState(undefined)
949
+ React.useEffect(() => {
950
+ if (!enabled) return undefined
951
+ let live = true
952
+ const read = () => {
953
+ Promise.resolve()
954
+ .then(() => githubCall('status'))
955
+ .then(next => { if (live) setStatus(next) })
956
+ .catch(() => { if (live) setStatus({ signedIn: false }) })
957
+ }
958
+ read()
959
+ const onFocus = () => { read() }
960
+ window.addEventListener('focus', onFocus)
961
+ return () => {
962
+ live = false
963
+ window.removeEventListener('focus', onFocus)
964
+ }
965
+ }, [enabled])
872
966
  if (!enabled) return null
873
- return React.createElement('div', { style: { padding: '2px 8px 4px', minWidth: 0 } },
967
+ const signedIn = status !== undefined && status.signedIn === true
968
+ const label = status === undefined
969
+ ? 'GitHub · checking…'
970
+ : signedIn ? `GitHub · ${String(status.login)}` : 'GitHub · signed out'
971
+ return React.createElement('div', { style: { padding: '2px 8px 0', minWidth: 0 } },
874
972
  React.createElement('button', {
875
973
  type: 'button',
876
- 'data-polish-github': 'entry',
877
- title: 'Add a workspace from GitHub',
878
- 'aria-label': 'Add a workspace from GitHub',
879
- onClick: () => githubStore.set(true),
974
+ 'data-polish-github': 'status',
975
+ 'data-polish-github-state': signedIn ? 'in' : status === undefined ? 'checking' : 'out',
976
+ title: signedIn
977
+ ? 'Add a workspace from GitHub'
978
+ : 'Sign in with the GitHub CLI (gh auth login), then add a repository as a workspace',
979
+ 'aria-label': label,
980
+ onClick: () => githubStore.open(),
981
+ style: FOOT_ROW_STYLE,
982
+ onMouseEnter: event => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
983
+ onMouseLeave: event => { event.currentTarget.style.background = 'transparent' },
984
+ }, label))
985
+ }
986
+
987
+ /** One row of the add-workspace source menu. */
988
+ const SOURCE_ROW_STYLE = {
989
+ display: 'flex',
990
+ flexDirection: 'column',
991
+ alignItems: 'flex-start',
992
+ gap: '2px',
993
+ width: '100%',
994
+ padding: '8px 10px',
995
+ border: 'none',
996
+ borderRadius: 'var(--dsw-radius-sm, 6px)',
997
+ background: 'transparent',
998
+ color: 'inherit',
999
+ font: 'inherit',
1000
+ textAlign: 'left',
1001
+ cursor: 'pointer',
1002
+ }
1003
+
1004
+ /**
1005
+ * The "+" add-workspace flow: choose where the workspace comes from.
1006
+ *
1007
+ * The Harness shows its own `Add workspace…` row only while this slot is
1008
+ * occupied, and a single-owner slot means this dialog stands in for the folder
1009
+ * picker. It therefore offers both sources: the machine (the Host's own OS
1010
+ * chooser when the composition serves the `native` capability, otherwise a
1011
+ * folder list driven by the browse primitives) and GitHub.
1012
+ *
1013
+ * @param props - the slot owner's `open`/`busy`/`onPicked`/`onCancel`/`onError`,
1014
+ * our lazy `configForms`, and the directory-picker calls.
1015
+ * @returns the flow, or null while it is closed or switched off.
1016
+ */
1017
+ function WorkspaceSourceFlow(props) {
1018
+ const enabled = useSwitch(props.configForms, 'workspaceSources', true)
1019
+ const [step, setStep] = React.useState('menu')
1020
+ const [listing, setListing] = React.useState(undefined)
1021
+ const [error, setError] = React.useState('')
1022
+ const [busy, setBusy] = React.useState(false)
1023
+
1024
+ const cancel = () => {
1025
+ if (typeof props.onCancel === 'function') props.onCancel()
1026
+ }
1027
+
1028
+ // Escape closes the flow. Declared with the other hooks, before the early
1029
+ // return, so the hook order stays fixed while the flow opens and closes.
1030
+ React.useEffect(() => {
1031
+ if (!enabled || props.open !== true) return undefined
1032
+ const onKey = event => { if (event.key === 'Escape') cancel() }
1033
+ document.addEventListener('keydown', onKey)
1034
+ return () => document.removeEventListener('keydown', onKey)
1035
+ }, [enabled, props.open])
1036
+
1037
+ if (!enabled || props.open !== true) return null
1038
+
1039
+ const fail = message => {
1040
+ setBusy(false)
1041
+ setError(message)
1042
+ }
1043
+ const messageOf = reason => (reason instanceof Error ? reason.message : String(reason))
1044
+
1045
+ const land = path => {
1046
+ setBusy(true)
1047
+ setError('')
1048
+ Promise.resolve()
1049
+ .then(() => props.onPicked(path))
1050
+ .catch(reason => fail(messageOf(reason)))
1051
+ }
1052
+
1053
+ const browse = path => {
1054
+ setBusy(true)
1055
+ setError('')
1056
+ Promise.resolve()
1057
+ .then(() => foldersCall({ action: 'ls', path }))
1058
+ .then(level => {
1059
+ setListing(level)
1060
+ setBusy(false)
1061
+ setStep('browse')
1062
+ })
1063
+ .catch(reason => fail(messageOf(reason)))
1064
+ }
1065
+
1066
+ /**
1067
+ * Start the folder browser at the machine's roots.
1068
+ *
1069
+ * This deliberately does not call the Host's directory picker: a composed
1070
+ * picker serves either `browse` or `native`, never both, and the browser build
1071
+ * composes the one whose chooser cannot open there — a `pick` call would wait
1072
+ * for a dialog that never appears. Our own listing behaves the same in the app
1073
+ * and in the browser, and hands the chosen path back through `onPicked`.
1074
+ */
1075
+ const chooseOnPc = () => {
1076
+ if (busy) return
1077
+ setBusy(true)
1078
+ setError('')
1079
+ Promise.resolve()
1080
+ .then(() => foldersCall({ action: 'roots' }))
1081
+ .then(payload => {
1082
+ const roots = Array.isArray(payload.roots) ? payload.roots : []
1083
+ setListing({
1084
+ path: '',
1085
+ parent: null,
1086
+ entries: roots.map(root => ({ name: root.label, path: root.path })),
1087
+ })
1088
+ setBusy(false)
1089
+ setStep('browse')
1090
+ })
1091
+ .catch(reason => fail(messageOf(reason)))
1092
+ }
1093
+
1094
+ const openGithub = () => {
1095
+ githubStore.open(path => props.onPicked(path))
1096
+ cancel()
1097
+ }
1098
+
1099
+ const rows = step === 'menu'
1100
+ ? [
1101
+ React.createElement('button', {
1102
+ key: 'pc',
1103
+ type: 'button',
1104
+ 'data-polish-source': 'machine',
1105
+ disabled: busy,
1106
+ onClick: chooseOnPc,
1107
+ style: SOURCE_ROW_STYLE,
1108
+ onMouseEnter: event => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
1109
+ onMouseLeave: event => { event.currentTarget.style.background = 'transparent' },
1110
+ },
1111
+ React.createElement('span', { style: { fontWeight: 500 } }, 'On this PC'),
1112
+ React.createElement('span', { style: { opacity: 0.7 } }, 'Choose a folder on this machine')),
1113
+ React.createElement('button', {
1114
+ key: 'github',
1115
+ type: 'button',
1116
+ 'data-polish-source': 'github',
1117
+ disabled: busy,
1118
+ onClick: openGithub,
1119
+ style: SOURCE_ROW_STYLE,
1120
+ onMouseEnter: event => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
1121
+ onMouseLeave: event => { event.currentTarget.style.background = 'transparent' },
1122
+ },
1123
+ React.createElement('span', { style: { fontWeight: 500 } }, 'GitHub repository…'),
1124
+ React.createElement('span', { style: { opacity: 0.7 } }, 'Clone one of your repositories as a workspace')),
1125
+ ]
1126
+ : [
1127
+ React.createElement('div', {
1128
+ key: 'head',
1129
+ style: { display: 'flex', alignItems: 'center', gap: '8px', padding: '2px 4px 6px', minWidth: 0 },
1130
+ },
1131
+ React.createElement('span', {
1132
+ style: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
1133
+ title: listing !== undefined ? listing.path : '',
1134
+ }, listing === undefined || listing.path === '' ? 'This PC' : listing.path),
1135
+ listing === undefined || listing.path === ''
1136
+ ? null
1137
+ : React.createElement('button', {
1138
+ type: 'button',
1139
+ 'data-polish-pick': 'here',
1140
+ disabled: busy,
1141
+ onClick: () => { if (listing !== undefined) land(listing.path) },
1142
+ style: { ...FOOT_ROW_STYLE, color: 'var(--dsw-alias-label-primary, inherit)', background: 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
1143
+ }, 'Use this folder')),
1144
+ ...(listing === undefined || listing.parent === null || listing.parent === undefined
1145
+ ? []
1146
+ : [React.createElement('button', {
1147
+ key: 'up',
1148
+ type: 'button',
1149
+ 'data-polish-folder': '..',
1150
+ disabled: busy,
1151
+ onClick: () => browse(listing.parent),
1152
+ style: SOURCE_ROW_STYLE,
1153
+ onMouseEnter: event => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
1154
+ onMouseLeave: event => { event.currentTarget.style.background = 'transparent' },
1155
+ }, React.createElement('span', { title: listing.parent }, '..'))]),
1156
+ ...(listing === undefined
1157
+ ? []
1158
+ : listing.entries.map(entry => React.createElement('button', {
1159
+ key: entry.path,
1160
+ type: 'button',
1161
+ 'data-polish-folder': entry.name,
1162
+ disabled: busy,
1163
+ onClick: () => browse(entry.path),
1164
+ style: SOURCE_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
+ }, React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title: entry.path }, entry.name)))),
1168
+ ]
1169
+
1170
+ // The flow is rendered inside the Shell's add-workspace popover, which is
1171
+ // narrow and sits under the sidebar's own stacking order. A portal to the body
1172
+ // puts it above everything and keeps the rows readable at their own width.
1173
+ return ReactDOM.createPortal(
1174
+ React.createElement('div', {
1175
+ style: GITHUB_BACKDROP_STYLE,
1176
+ 'data-polish-flow-backdrop': step,
1177
+ onPointerDown: event => { if (event.target === event.currentTarget) cancel() },
1178
+ },
1179
+ React.createElement('div', {
1180
+ 'data-polish-flow': step,
1181
+ role: 'dialog',
1182
+ 'aria-label': 'Add a workspace',
880
1183
  style: {
881
1184
  display: 'flex',
882
- alignItems: 'center',
883
- gap: '6px',
884
- maxWidth: '100%',
885
- minWidth: 0,
886
- padding: '2px 6px',
887
- border: 'none',
888
- borderRadius: 'var(--dsw-radius-sm, 6px)',
889
- background: 'transparent',
890
- color: 'var(--dsw-alias-label-tertiary, inherit)',
891
- font: 'inherit',
1185
+ flexDirection: 'column',
1186
+ gap: '2px',
1187
+ width: 'min(360px, calc(100vw - 32px))',
1188
+ maxHeight: 'min(420px, calc(100vh - 96px))',
1189
+ padding: '6px',
1190
+ overflowY: 'auto',
1191
+ borderRadius: 'var(--dsw-radius-lg, 12px)',
1192
+ background: 'var(--dsw-specific-menu, Canvas)',
1193
+ boxShadow: 'var(--dsw-elevation-prominent, 0 8px 24px rgba(0,0,0,0.18))',
892
1194
  fontSize: '12px',
893
1195
  lineHeight: '18px',
894
- fontVariantNumeric: 'tabular-nums',
895
- whiteSpace: 'nowrap',
896
- overflow: 'hidden',
897
- textOverflow: 'ellipsis',
898
- cursor: 'pointer',
1196
+ color: 'var(--dsw-alias-label-secondary, inherit)',
899
1197
  },
900
- onMouseEnter: event => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
901
- onMouseLeave: event => { event.currentTarget.style.background = 'transparent' },
902
1198
  },
903
- React.createElement('svg', {
904
- width: 14,
905
- height: 14,
906
- viewBox: '0 0 16 16',
907
- fill: 'none',
908
- stroke: 'currentColor',
909
- strokeWidth: 1.2,
910
- strokeLinecap: 'round',
911
- strokeLinejoin: 'round',
912
- 'aria-hidden': 'true',
913
- style: { flex: 'none' },
914
- },
915
- React.createElement('path', { d: 'M2.5 5.5h4l1.2 1.5h5.8v6.5h-11z' }),
916
- React.createElement('path', { d: 'M8 9.8v3.4M6.3 11.5h3.4' })),
917
- 'Add from GitHub'))
1199
+ error !== ''
1200
+ ? React.createElement('div', { style: { padding: '4px 6px', color: 'var(--dsw-alias-label-error, #b42318)' } }, error)
1201
+ : null,
1202
+ ...rows,
1203
+ step === 'menu'
1204
+ ? null
1205
+ : React.createElement('button', {
1206
+ type: 'button',
1207
+ 'data-polish-pick': 'back',
1208
+ onClick: () => { setStep('menu'); setListing(undefined); setError('') },
1209
+ style: { ...FOOT_ROW_STYLE, justifyContent: 'flex-start' },
1210
+ }, 'Back'),
1211
+ React.createElement('button', {
1212
+ type: 'button',
1213
+ 'data-polish-pick': 'cancel',
1214
+ onClick: cancel,
1215
+ style: { ...FOOT_ROW_STYLE, justifyContent: 'flex-start' },
1216
+ }, 'Cancel'))),
1217
+ document.body)
918
1218
  }
919
1219
 
920
1220
  return {
@@ -995,12 +1295,29 @@ return {
995
1295
  id: 'polish-github',
996
1296
  inject: () => ({ configForms, createWorkspace }),
997
1297
  }, GitHubPanel))
1298
+ // The footer keeps the sign-in state, not an add button: adding a
1299
+ // workspace belongs in the "+" menu, where the Shell says it does.
998
1300
  githubCtx.slots.inject('sidebar.footer.action', () => githubCtx.slots.register({
999
1301
  name: 'sidebar.footer.action',
1000
- id: 'polish-github-entry',
1302
+ id: 'polish-github-status',
1001
1303
  order: 4,
1002
1304
  inject: () => ({ configForms }),
1003
- }, GithubEntry))
1305
+ }, GithubStatus))
1004
1306
  })
1307
+ // The "+" menu's `Add workspace…` row exists only while a plugin occupies
1308
+ // this single-owner hole, so occupying it means standing in for the folder
1309
+ // picker: the flow offers the machine and GitHub as sources. It needs no
1310
+ // Remote injection: the folder list and the clone both come from this
1311
+ // bundle's own Host routes, which is what makes the flow behave the same in
1312
+ // the browser and in the app.
1313
+ ctx.slots.inject('sidebar.workspaces.directoryFlow', () => ctx.slots.register({
1314
+ name: 'sidebar.workspaces.directoryFlow',
1315
+ // A single slot clashes only at the exact priority; another priority
1316
+ // shadows it and the LOWEST priority renders. The shipped pickers
1317
+ // register at 0, so -1 is what puts this flow in front of them without
1318
+ // making their own activation throw.
1319
+ priority: -1,
1320
+ inject: () => ({ configForms }),
1321
+ }, WorkspaceSourceFlow))
1005
1322
  },
1006
1323
  }
@@ -24,6 +24,12 @@
24
24
  "label": "Add a workspace from GitHub",
25
25
  "description": "Sign in with the GitHub CLI, browse your repositories, and clone one as a workspace.",
26
26
  "defaultOn": true
27
+ },
28
+ {
29
+ "key": "workspaceSources",
30
+ "label": "Add a workspace from this PC or GitHub",
31
+ "description": "Put an On this PC / GitHub repository source menu behind the Workspaces + button.",
32
+ "defaultOn": true
27
33
  }
28
34
  ]
29
35
  }
@@ -31,16 +31,34 @@ server rejects a bearer token for clone, which is why the header carries
31
31
  `x-access-token` basic auth. Every line of git output is scrubbed of the token
32
32
  before it can reach the browser, and the route answers `cache-control: no-store`.
33
33
 
34
- ## Why the entry point is the sidebar foot
35
-
36
- The "+" menu's **Add workspace** entry only appears when something occupies
37
- `sidebar.workspaces.directoryFlow`, and that slot is `kind: 'single'`: it already
38
- holds the folder picker, so occupying it would mean re-implementing folder
39
- choosing (browse *and* the native desktop picker) just to add a second source.
40
- Rather than replace the shipped picker, the feature adds its own entry in the
41
- sidebar foot next to it. If a multi-source workspace socket ever lands in the
42
- harness, only the registration moves; the route, the clone rules and the tests
43
- stay as they are.
34
+ ## How the entry point works
35
+
36
+ The "+" menu's **Add workspace…** entry only appears when something occupies
37
+ `sidebar.workspaces.directoryFlow`, and that slot is `kind: 'single'` — whoever
38
+ takes it stands in for the folder picker. This bundle takes it and offers the
39
+ two sources the owner asked for:
40
+
41
+ - **On this PC** — a folder browser driven by this bundle's own route
42
+ (`api/polish.folders`, actions `roots` and `ls`): roots, a `..` row for going
43
+ back, directories only, hidden and system entries filtered out, and **Use this
44
+ folder** hands the current path to the flow's `onPicked`.
45
+ - **GitHub repository…** — the dialog described above. It opens on behalf of the
46
+ flow, so a finished clone hands its path back through the flow's `onPicked`,
47
+ and the Shell creates the workspace exactly as it does for a picked folder.
48
+
49
+ ### Why the browser does not borrow the Host's chooser
50
+
51
+ A composed directory picker serves exactly one capability: `browse` answers
52
+ `list` and `createDirectory`, `native` answers `pick`. The web profile composes
53
+ the picker whose chooser cannot open there, so `directoryPicker.pick()` waits on
54
+ a dialog that never appears — the first build of this flow did exactly that and
55
+ the row spun forever. Owning the listing keeps one behaviour in the app and in
56
+ the browser, needs no capability guess, and does not break when a profile mounts
57
+ a different picker.
58
+
59
+ The tray of the foot keeps the sign-in state (`GitHub · <login>`, or
60
+ `GitHub · signed out`) next to the balance, so the account is visible without
61
+ opening a dialog; clicking it opens the repository dialog directly.
44
62
 
45
63
  ## Not used from the AryaAI-era plugin
46
64
 
@@ -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.0"
39
45
 
40
46
  /**
41
47
  * The two pure decisions behind the balance line: which wallet to show, and how
@@ -627,6 +633,7 @@ window.__ModuleLoader__.load({
627
633
  */
628
634
  const BALANCE_RETRY_DELAYS = [1500, 5000, 15000, 30000]
629
635
 
636
+
630
637
  /**
631
638
  * The remaining-balance line in the sidebar foot.
632
639
  *
@@ -788,7 +795,12 @@ window.__ModuleLoader__.load({
788
795
  */
789
796
  const githubStore = (() => {
790
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
791
802
  const listeners = new Set()
803
+ const publish = () => { for (const listener of listeners) listener() }
792
804
  return {
793
805
  isOpen: () => open,
794
806
  subscribe(listener) {
@@ -798,7 +810,21 @@ window.__ModuleLoader__.load({
798
810
  set(next) {
799
811
  if (open === next) return
800
812
  open = next
801
- for (const listener of listeners) listener()
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
802
828
  },
803
829
  }
804
830
  })()
@@ -823,6 +849,28 @@ window.__ModuleLoader__.load({
823
849
  return payload.value
824
850
  }
825
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
+
826
874
  /** Modal backdrop and panel skin, matching the Shell's own dialogs. */
827
875
  const GITHUB_BACKDROP_STYLE = {
828
876
  position: 'fixed',
@@ -946,7 +994,15 @@ window.__ModuleLoader__.load({
946
994
  setError('')
947
995
  Promise.resolve()
948
996
  .then(() => githubCall('clone', { owner: repo.owner, name: repo.name }))
949
- .then(result => Promise.resolve(props.createWorkspace(result.path)).then(() => result))
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
+ })
950
1006
  .then(result => {
951
1007
  setBusy('')
952
1008
  githubStore.set(false)
@@ -1023,59 +1079,309 @@ window.__ModuleLoader__.load({
1023
1079
  , document.body)
1024
1080
  }
1025
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
+
1026
1104
  /**
1027
- * The footer row that opens the GitHub dialog.
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
+ *
1028
1110
  * @param props - our lazy `configForms`.
1029
1111
  * @returns the row, or null when the feature is switched off.
1030
1112
  */
1031
- function GithubEntry(props) {
1113
+ function GithubStatus(props) {
1032
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])
1033
1133
  if (!enabled) return null
1034
- return React.createElement('div', { style: { padding: '2px 8px 4px', minWidth: 0 } },
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 } },
1035
1139
  React.createElement('button', {
1036
1140
  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),
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',
1041
1350
  style: {
1042
1351
  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',
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))',
1053
1361
  fontSize: '12px',
1054
1362
  lineHeight: '18px',
1055
- fontVariantNumeric: 'tabular-nums',
1056
- whiteSpace: 'nowrap',
1057
- overflow: 'hidden',
1058
- textOverflow: 'ellipsis',
1059
- cursor: 'pointer',
1363
+ color: 'var(--dsw-alias-label-secondary, inherit)',
1060
1364
  },
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
1365
  },
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
- },
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'))
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)
1079
1385
  }
1080
1386
 
1081
1387
  return {
@@ -1156,13 +1462,30 @@ window.__ModuleLoader__.load({
1156
1462
  id: 'polish-github',
1157
1463
  inject: () => ({ configForms, createWorkspace }),
1158
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.
1159
1467
  githubCtx.slots.inject('sidebar.footer.action', () => githubCtx.slots.register({
1160
1468
  name: 'sidebar.footer.action',
1161
- id: 'polish-github-entry',
1469
+ id: 'polish-github-status',
1162
1470
  order: 4,
1163
1471
  inject: () => ({ configForms }),
1164
- }, GithubEntry))
1472
+ }, GithubStatus))
1165
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))
1166
1489
  },
1167
1490
  }
1168
1491
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aryacode/dsh-aryacode-polish",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "AryaCode Polish: one install for the small things that make AryaCode feel finished. One Plugins row, one switch per quality-of-life feature.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -51,7 +51,7 @@
51
51
  "scripts": {
52
52
  "build:client": "node scripts/build-client.mjs",
53
53
  "prepack": "node scripts/build-client.mjs",
54
- "test": "node --test test/patch.spec.mjs test/config.spec.mjs test/registry.spec.mjs test/cost.spec.mjs test/money.spec.mjs test/account.spec.mjs test/github.spec.mjs test/docs.spec.mjs test/manifests.spec.mjs test/client-face.spec.mjs"
54
+ "test": "node --test test/patch.spec.mjs test/config.spec.mjs test/registry.spec.mjs test/cost.spec.mjs test/money.spec.mjs test/account.spec.mjs test/github.spec.mjs test/folders.spec.mjs test/docs.spec.mjs test/manifests.spec.mjs test/client-face.spec.mjs"
55
55
  },
56
56
  "dependencies": {
57
57
  "zod": "^4.4.3"