@aryacode/dsh-aryacode-polish 0.6.0 → 0.7.3

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,54 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.3] - 2026-10-28
4
+
5
+ ### Fixed
6
+
7
+ - **A sign-out that only looked like one.** gh auth logout --user removes the
8
+ account it names, and a machine with a second account in the CLI keyring makes
9
+ that one active, so the dialog quietly carried on as somebody else while the
10
+ sidebar still named the old account. Signing out now reports the account the
11
+ CLI fell back to, offers **Sign out all** when more than one account exists,
12
+ and the footer line follows the dialog immediately instead of waiting for the
13
+ next window focus.
14
+
15
+ ## [0.7.0] - 2026-10-28
16
+
17
+ ### Added
18
+
19
+ - **Sign in and sign out of GitHub from the dialog.** Sign-in is still the GitHub
20
+ CLI's own session — this bundle stores no token — but it no longer sends the
21
+ owner to a terminal: the dialog runs `gh auth login --web`, shows the one-time
22
+ code it prints with a copy button and the device page, and refreshes the
23
+ repository list as soon as the CLI reports success. A signed-in machine gets a
24
+ two-step **Sign out** (`gh auth logout --hostname github.com --user <login>`),
25
+ and a pending sign-in can be cancelled. The footer row shows which state the
26
+ machine is in, so the answer to "am I signed in?" is visible without opening
27
+ anything.
28
+
29
+ ## [0.6.2] - 2026-10-28
30
+
31
+ ### Fixed
32
+
33
+ - **The "+" menu and the GitHub dialog are opaque again in the desktop app.**
34
+ Both are portaled to `document.body`, which puts them outside the themed tree,
35
+ and they painted themselves with the harness menu fill `--dsw-specific-menu`
36
+ (`#f8f9fa94`, 58% alpha) relying on `backdrop-filter` to make that read solid.
37
+ The AryaCode desktop build does not composite that blur, so the page showed
38
+ through the panel. Every portaled surface now lays that fill over an opaque
39
+ `Canvas` base and keeps the blur only where the platform honours it.
40
+
41
+ ## [0.6.1] - 2026-10-28
42
+
43
+ ### Fixed
44
+
45
+ - **"On this PC" opens the real Windows folder dialog in the desktop app again.**
46
+ The desktop preload exposes the OS chooser as `__DSH_DIRECTORY_PICKER__.pick()`
47
+ — the same bridge the shipped native picker uses — and the flow now calls it
48
+ directly, so picking a folder behaves exactly like the picker it stands in for.
49
+ The in-app folder list is now only the browser fallback, where no such bridge
50
+ exists and the composed picker's `pick` would wait on a dialog that cannot open.
51
+
3
52
  ## [0.6.0] - 2026-10-28
4
53
 
5
54
  ### Changed
package/client/face.js CHANGED
@@ -252,6 +252,24 @@ const PILL_STYLE = {
252
252
  /** The label half of the pill, cut with an ellipsis rather than wrapped. */
253
253
  const PILL_LABEL_STYLE = { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }
254
254
 
255
+ /**
256
+ * The menu skin of the harness, made opaque on purpose.
257
+ *
258
+ * `--dsw-specific-menu` is a translucent fill (`#f8f9fa94`) that only reads as a
259
+ * solid surface because the harness pairs it with `backdrop-filter`. A plugin
260
+ * face has to portal its panels to `document.body`, and in the AryaCode desktop
261
+ * build that blur does not composite: the panel then showed the page behind it
262
+ * through a 58% fill. So the token stays as a tint over an opaque backdrop
263
+ * colour, and the blur is kept only where the platform actually honours it.
264
+ */
265
+ const PANEL_SURFACE = {
266
+ backgroundColor: 'Canvas',
267
+ backgroundImage:
268
+ 'linear-gradient(var(--dsw-specific-menu, rgba(248, 249, 250, 0.94)), var(--dsw-specific-menu, rgba(248, 249, 250, 0.94)))',
269
+ backdropFilter: 'var(--dsw-menu-backdrop-filter, none)',
270
+ border: '0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12))',
271
+ }
272
+
255
273
  /** The dialog surface, copied from the harness `stat-dialog.module.css` skin. */
256
274
  const PANEL_STYLE = {
257
275
  position: 'fixed',
@@ -261,10 +279,8 @@ const PANEL_STYLE = {
261
279
  minWidth: 'min(300px, calc(100vw - 24px))',
262
280
  maxWidth: 'min(440px, calc(100vw - 24px))',
263
281
  padding: '16px',
264
- border: 0,
265
282
  borderRadius: 'var(--dsw-radius-lg, 12px)',
266
- background: 'var(--dsw-specific-menu, Canvas)',
267
- backdropFilter: 'var(--dsw-menu-backdrop-filter, none)',
283
+ ...PANEL_SURFACE,
268
284
  boxShadow: 'var(--dsw-elevation-prominent, 0 10px 30px rgba(0, 0, 0, 0.18))',
269
285
  fontSize: '12px',
270
286
  lineHeight: '18px',
@@ -662,6 +678,29 @@ const githubStore = (() => {
662
678
  }
663
679
  })()
664
680
 
681
+ /**
682
+ * The last GitHub status anyone read, shared by the dialog and the footer line.
683
+ *
684
+ * The footer used to re-read only on window focus, so signing out in the dialog
685
+ * left `GitHub · KonnectSuite` on screen while the CLI was already signed out (or
686
+ * signed in as another account). One store, written by every read, keeps them equal.
687
+ */
688
+ const githubStatusStore = (() => {
689
+ let value
690
+ const listeners = new Set()
691
+ return {
692
+ get: () => value,
693
+ subscribe(listener) {
694
+ listeners.add(listener)
695
+ return () => { listeners.delete(listener) }
696
+ },
697
+ set(next) {
698
+ value = next
699
+ for (const listener of listeners) listener()
700
+ },
701
+ }
702
+ })()
703
+
665
704
  /**
666
705
  * Post one action to the GitHub route.
667
706
  * @param action - `status`, `repos` or `clone`.
@@ -731,6 +770,20 @@ const GITHUB_LIST_STYLE = {
731
770
  borderTop: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.12))',
732
771
  }
733
772
 
773
+ /** A secondary button in the sign-in block, sized like the harness's own. */
774
+ const GITHUB_BUTTON_STYLE = {
775
+ display: 'inline-flex',
776
+ alignItems: 'center',
777
+ justifyContent: 'center',
778
+ padding: '4px 10px',
779
+ border: '0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.16))',
780
+ borderRadius: 'var(--dsw-radius-sm, 6px)',
781
+ background: 'transparent',
782
+ color: 'inherit',
783
+ font: 'inherit',
784
+ cursor: 'pointer',
785
+ }
786
+
734
787
  const GITHUB_ROW_STYLE = {
735
788
  display: 'flex',
736
789
  alignItems: 'center',
@@ -746,8 +799,7 @@ const GITHUB_ROW_STYLE = {
746
799
  borderBottom: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.08))',
747
800
  }
748
801
 
749
- const GITHUB_INPUT_STYLE = {
750
- width: '100%',
802
+ const GITHUB_INPUT_STYLE = { width: '100%',
751
803
  boxSizing: 'border-box',
752
804
  padding: '6px 8px',
753
805
  font: 'inherit',
@@ -777,6 +829,33 @@ function GitHubPanel(props) {
777
829
  const [query, setQuery] = React.useState('')
778
830
  const [error, setError] = React.useState('')
779
831
  const [busy, setBusy] = React.useState('')
832
+ const [signIn, setSignIn] = React.useState({ state: 'idle', code: '', url: '', reason: '' })
833
+ const [confirmOut, setConfirmOut] = React.useState(false)
834
+ const [confirmAll, setConfirmAll] = React.useState(false)
835
+ const [notice, setNotice] = React.useState('')
836
+ const [copied, setCopied] = React.useState(false)
837
+
838
+ /** Put the one-time code on the clipboard, so it can be pasted in the browser. */
839
+ const copyCode = () => {
840
+ const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard
841
+ if (clipboard === undefined || typeof clipboard.writeText !== 'function') return
842
+ Promise.resolve(clipboard.writeText(signIn.code))
843
+ .then(() => {
844
+ setCopied(true)
845
+ setTimeout(() => setCopied(false), 2000)
846
+ })
847
+ .catch(() => setCopied(false))
848
+ }
849
+
850
+ /** Re-read the session, and the repository list when it is signed in. */
851
+ const refresh = () => Promise.resolve()
852
+ .then(() => githubCall('status'))
853
+ .then(status => {
854
+ setAccount(status)
855
+ githubStatusStore.set(status)
856
+ if (status.signedIn !== true) return undefined
857
+ return githubCall('repos').then(list => setRepos(Array.isArray(list.repos) ? list.repos : []))
858
+ })
780
859
 
781
860
  React.useEffect(() => {
782
861
  if (!open || !enabled) return undefined
@@ -785,17 +864,9 @@ function GitHubPanel(props) {
785
864
  setError('')
786
865
  setBusy('')
787
866
  setQuery('')
788
- Promise.resolve()
789
- .then(() => githubCall('status'))
790
- .then(status => {
791
- if (!live) return undefined
792
- setAccount(status)
793
- if (status.signedIn !== true) return undefined
794
- return githubCall('repos').then(list => {
795
- if (!live) return
796
- setRepos(Array.isArray(list.repos) ? list.repos : [])
797
- })
798
- })
867
+ setConfirmOut(false)
868
+ setSignIn({ state: 'idle', code: '', url: '', reason: '' })
869
+ refresh()
799
870
  .then(() => { if (live) setPhase('ready') })
800
871
  .catch(reason => {
801
872
  if (!live) return
@@ -805,6 +876,29 @@ function GitHubPanel(props) {
805
876
  return () => { live = false }
806
877
  }, [open, enabled])
807
878
 
879
+ // The device flow is a long poll: ask the Host what the CLI child reports.
880
+ // `starting` has to be polled too — the Host answers `starting` the moment the
881
+ // child is spawned and only reports the code a tick later, so polling on
882
+ // `pending` alone left the dialog asking for a code that had already arrived.
883
+ React.useEffect(() => {
884
+ if (!open || (signIn.state !== 'pending' && signIn.state !== 'starting')) return undefined
885
+ const timer = setInterval(() => {
886
+ Promise.resolve()
887
+ .then(() => githubCall('loginStatus'))
888
+ .then(answer => {
889
+ setSignIn({ state: answer.state, code: answer.code ?? '', url: answer.url ?? '', reason: answer.reason ?? '' })
890
+ if (answer.state === 'succeeded') {
891
+ setPhase('loading')
892
+ return refresh().then(() => setPhase('ready'))
893
+ }
894
+ if (answer.state === 'failed' || answer.state === 'cancelled') setError(answer.reason ?? 'Sign-in did not finish.')
895
+ return undefined
896
+ })
897
+ .catch(reason => setError(reason instanceof Error ? reason.message : String(reason)))
898
+ }, 2000)
899
+ return () => clearInterval(timer)
900
+ }, [open, signIn.state])
901
+
808
902
  React.useEffect(() => {
809
903
  if (!open) return undefined
810
904
  const onKey = event => { if (event.key === 'Escape') githubStore.set(false) }
@@ -849,6 +943,78 @@ function GitHubPanel(props) {
849
943
  })
850
944
  }
851
945
 
946
+ /**
947
+ * Start the CLI's device flow. The Host runs it, so this dialog only renders
948
+ * the code the CLI printed and the page it wants opened.
949
+ */
950
+ const beginSignIn = () => {
951
+ setError('')
952
+ setSignIn({ state: 'starting', code: '', url: '', reason: '' })
953
+ Promise.resolve()
954
+ .then(() => githubCall('login'))
955
+ .then(answer => setSignIn({
956
+ state: answer.state ?? 'failed',
957
+ code: answer.code ?? '',
958
+ url: answer.url ?? '',
959
+ reason: answer.reason ?? '',
960
+ }))
961
+ .catch(reason => {
962
+ setSignIn({ state: 'failed', code: '', url: '', reason: '' })
963
+ setError(reason instanceof Error ? reason.message : String(reason))
964
+ })
965
+ }
966
+
967
+ const cancelSignIn = () => {
968
+ Promise.resolve()
969
+ .then(() => githubCall('loginCancel'))
970
+ .then(() => setSignIn({ state: 'idle', code: '', url: '', reason: '' }))
971
+ .catch(() => setSignIn({ state: 'idle', code: '', url: '', reason: '' }))
972
+ }
973
+
974
+ /** Sign the CLI out, which is what every read in this feature depends on. */
975
+ const signOutNow = () => {
976
+ setConfirmOut(false)
977
+ setBusy('sign-out')
978
+ setError('')
979
+ setNotice('')
980
+ Promise.resolve()
981
+ .then(() => githubCall('logout'))
982
+ .then(answer => {
983
+ const remaining = typeof answer.remaining === 'string' ? answer.remaining : ''
984
+ setAccount(undefined)
985
+ setRepos([])
986
+ setSignIn({ state: 'idle', code: '', url: '', reason: '' })
987
+ setNotice(remaining === ''
988
+ ? `Signed out of ${String(answer.login)}.`
989
+ : `Signed out of ${String(answer.login)}. The GitHub CLI is now signed in as ${remaining}.`)
990
+ return refresh()
991
+ })
992
+ .catch(reason => setError(reason instanceof Error ? reason.message : String(reason)))
993
+ .then(() => setBusy(''))
994
+ }
995
+
996
+ /** Sign every account in the CLI's keyring out, so no silent fallback is left. */
997
+ const signOutAllNow = () => {
998
+ setConfirmAll(false)
999
+ setBusy('sign-out-all')
1000
+ setError('')
1001
+ setNotice('')
1002
+ Promise.resolve()
1003
+ .then(() => githubCall('logoutAll'))
1004
+ .then(answer => {
1005
+ const logins = Array.isArray(answer.logins) ? answer.logins.map(String) : []
1006
+ setAccount(undefined)
1007
+ setRepos([])
1008
+ setSignIn({ state: 'idle', code: '', url: '', reason: '' })
1009
+ setNotice(logins.length === 0
1010
+ ? 'No GitHub account was signed in.'
1011
+ : `Signed out of ${logins.join(', ')}.`)
1012
+ return refresh()
1013
+ })
1014
+ .catch(reason => setError(reason instanceof Error ? reason.message : String(reason)))
1015
+ .then(() => setBusy(''))
1016
+ }
1017
+
852
1018
  const rows = visible.slice(0, 200).map(repo => React.createElement('button', {
853
1019
  key: repo.fullName,
854
1020
  type: 'button',
@@ -876,18 +1042,45 @@ function GitHubPanel(props) {
876
1042
  React.createElement('div', { style: GITHUB_CARD_STYLE, role: 'dialog', 'aria-label': 'Add a workspace from GitHub' },
877
1043
  React.createElement('div', { style: { ...PANEL_TITLE_STYLE, marginBottom: '4px' } },
878
1044
  React.createElement('span', null, 'Add a workspace from GitHub'),
879
- React.createElement('button', {
880
- type: 'button',
881
- onClick: () => githubStore.set(false),
882
- style: { border: 'none', background: 'transparent', color: 'inherit', font: 'inherit', cursor: 'pointer', opacity: 0.7 },
883
- 'aria-label': 'Close',
884
- }, '×')),
1045
+ React.createElement('span', { style: { display: 'flex', alignItems: 'center', gap: '10px' } },
1046
+ signedIn && (Array.isArray(account?.accounts) ? account.accounts.length : 0) > 1
1047
+ ? React.createElement('button', {
1048
+ type: 'button',
1049
+ 'data-polish-github': 'signoutall',
1050
+ disabled: busy === 'sign-out-all',
1051
+ onClick: () => { if (confirmAll) signOutAllNow(); else setConfirmAll(true) },
1052
+ title: 'Sign every GitHub account in the CLI out of this machine',
1053
+ style: { border: 'none', background: 'transparent', color: 'inherit', font: 'inherit', cursor: 'pointer', opacity: 0.7, padding: 0 },
1054
+ }, busy === 'sign-out-all' ? 'Signing out…' : confirmAll ? 'Sign out all now?' : 'Sign out all')
1055
+ : null,
1056
+ signedIn
1057
+ ? React.createElement('button', {
1058
+ type: 'button',
1059
+ 'data-polish-github': 'signout',
1060
+ disabled: busy === 'sign-out',
1061
+ onClick: () => { if (confirmOut) signOutNow(); else setConfirmOut(true) },
1062
+ title: 'Sign the GitHub CLI out of this machine',
1063
+ style: { border: 'none', background: 'transparent', color: 'inherit', font: 'inherit', cursor: 'pointer', opacity: 0.7, padding: 0 },
1064
+ }, busy === 'sign-out' ? 'Signing out…' : confirmOut ? 'Sign out now?' : 'Sign out')
1065
+ : null,
1066
+ React.createElement('button', {
1067
+ type: 'button',
1068
+ onClick: () => githubStore.set(false),
1069
+ style: { border: 'none', background: 'transparent', color: 'inherit', font: 'inherit', cursor: 'pointer', opacity: 0.7 },
1070
+ 'aria-label': 'Close',
1071
+ }, '×'))),
885
1072
  React.createElement('div', { style: { opacity: 0.75, minHeight: '18px' } },
886
1073
  phase === 'loading' && !signedIn ? 'Checking the GitHub CLI session…' : null,
887
1074
  signedIn ? `Signed in as ${String(account.login)} · cloning into ${root}` : null,
888
1075
  !signedIn && phase !== 'loading'
889
- ? 'The GitHub CLI is not signed in. Run `gh auth login` in a terminal, then reopen this dialog.'
1076
+ ? 'GitHub is not signed in on this machine. The sign-in below is the GitHub CLI device flow, so no token is ever typed here or stored by this plugin.'
890
1077
  : null),
1078
+ notice === ''
1079
+ ? null
1080
+ : React.createElement('div', {
1081
+ 'data-polish-github': 'notice',
1082
+ style: { marginTop: '6px', opacity: 0.85 },
1083
+ }, notice),
891
1084
  signedIn
892
1085
  ? React.createElement('input', {
893
1086
  style: { ...GITHUB_INPUT_STYLE, marginTop: '10px' },
@@ -901,6 +1094,48 @@ function GitHubPanel(props) {
901
1094
  error !== ''
902
1095
  ? React.createElement('div', { style: { marginTop: '10px', color: 'var(--dsw-alias-label-error, #b42318)' } }, error)
903
1096
  : null,
1097
+ !signedIn && phase !== 'loading'
1098
+ ? React.createElement('div', { style: { marginTop: '12px' } },
1099
+ signIn.state === 'pending' || signIn.state === 'starting'
1100
+ ? React.createElement('div', null,
1101
+ React.createElement('div', { style: { marginBottom: '8px' } },
1102
+ signIn.state === 'starting' ? 'Asking the GitHub CLI for a code…' : 'Approve this one-time code in your browser:'),
1103
+ signIn.code === ''
1104
+ ? null
1105
+ : React.createElement('div', {
1106
+ 'data-polish-github': 'code',
1107
+ style: {
1108
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
1109
+ fontSize: '18px',
1110
+ letterSpacing: '2px',
1111
+ padding: '8px 10px',
1112
+ borderRadius: 'var(--dsw-radius-sm, 6px)',
1113
+ border: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.12))',
1114
+ },
1115
+ }, signIn.code),
1116
+ React.createElement('div', { style: { display: 'flex', gap: '8px', marginTop: '10px', flexWrap: 'wrap' } },
1117
+ React.createElement('button', { type: 'button', onClick: copyCode, style: GITHUB_BUTTON_STYLE, 'data-polish-github': 'copy' },
1118
+ copied ? 'Copied' : 'Copy code'),
1119
+ React.createElement('a', {
1120
+ href: signIn.url === '' ? 'https://github.com/login/device' : signIn.url,
1121
+ target: '_blank',
1122
+ rel: 'noreferrer',
1123
+ style: { ...GITHUB_BUTTON_STYLE, textDecoration: 'none', display: 'inline-flex', alignItems: 'center' },
1124
+ }, 'Open the device page'),
1125
+ React.createElement('button', { type: 'button', onClick: cancelSignIn, style: GITHUB_BUTTON_STYLE, 'data-polish-github': 'cancel' }, 'Cancel')))
1126
+ : React.createElement('div', null,
1127
+ React.createElement('button', {
1128
+ type: 'button',
1129
+ onClick: beginSignIn,
1130
+ // The attribute belongs on the element: inside `style` it is ignored,
1131
+ // which is exactly why the first verification could not find this button.
1132
+ 'data-polish-github': 'signin',
1133
+ style: GITHUB_BUTTON_STYLE,
1134
+ }, 'Sign in with GitHub'),
1135
+ signIn.state === 'failed' && signIn.reason !== ''
1136
+ ? React.createElement('div', { style: { marginTop: '8px', color: 'var(--dsw-alias-label-error, #b42318)' } }, signIn.reason)
1137
+ : null))
1138
+ : null,
904
1139
  signedIn
905
1140
  ? React.createElement('div', { style: GITHUB_LIST_STYLE, 'data-polish-github-list': String(visible.length) },
906
1141
  phase === 'loading' && visible.length === 0 ? React.createElement('div', { style: { padding: '10px 4px', opacity: 0.7 } }, 'Loading repositories…') : null,
@@ -945,6 +1180,9 @@ const FOOT_ROW_STYLE = {
945
1180
  */
946
1181
  function GithubStatus(props) {
947
1182
  const enabled = useSwitch(props.configForms, 'githubWorkspace', true)
1183
+ // The dialog publishes every status it reads, so a sign-out in the dialog lands
1184
+ // in this line immediately instead of waiting for the next window focus.
1185
+ const shared = React.useSyncExternalStore(githubStatusStore.subscribe, githubStatusStore.get)
948
1186
  const [status, setStatus] = React.useState(undefined)
949
1187
  React.useEffect(() => {
950
1188
  if (!enabled) return undefined
@@ -952,7 +1190,11 @@ function GithubStatus(props) {
952
1190
  const read = () => {
953
1191
  Promise.resolve()
954
1192
  .then(() => githubCall('status'))
955
- .then(next => { if (live) setStatus(next) })
1193
+ .then(next => {
1194
+ if (!live) return
1195
+ setStatus(next)
1196
+ githubStatusStore.set(next)
1197
+ })
956
1198
  .catch(() => { if (live) setStatus({ signedIn: false }) })
957
1199
  }
958
1200
  read()
@@ -964,15 +1206,16 @@ function GithubStatus(props) {
964
1206
  }
965
1207
  }, [enabled])
966
1208
  if (!enabled) return null
967
- const signedIn = status !== undefined && status.signedIn === true
968
- const label = status === undefined
1209
+ const current = shared === undefined ? status : shared
1210
+ const signedIn = current !== undefined && current.signedIn === true
1211
+ const label = current === undefined
969
1212
  ? 'GitHub · checking…'
970
- : signedIn ? `GitHub · ${String(status.login)}` : 'GitHub · signed out'
1213
+ : signedIn ? `GitHub · ${String(current.login)}` : 'GitHub · signed out'
971
1214
  return React.createElement('div', { style: { padding: '2px 8px 0', minWidth: 0 } },
972
1215
  React.createElement('button', {
973
1216
  type: 'button',
974
1217
  'data-polish-github': 'status',
975
- 'data-polish-github-state': signedIn ? 'in' : status === undefined ? 'checking' : 'out',
1218
+ 'data-polish-github-state': signedIn ? 'in' : current === undefined ? 'checking' : 'out',
976
1219
  title: signedIn
977
1220
  ? 'Add a workspace from GitHub'
978
1221
  : 'Sign in with the GitHub CLI (gh auth login), then add a repository as a workspace',
@@ -1064,16 +1307,33 @@ function WorkspaceSourceFlow(props) {
1064
1307
  }
1065
1308
 
1066
1309
  /**
1067
- * Start the folder browser at the machine's roots.
1310
+ * Hand folder choice to the Host.
1068
1311
  *
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`.
1312
+ * In the desktop app the preload exposes the real OS chooser as
1313
+ * `__DSH_DIRECTORY_PICKER__.pick()` — the same bridge the shipped native
1314
+ * picker uses — so the flow opens the Windows folder dialog and behaves like
1315
+ * the stock picker it stands in for. In a browser there is no such bridge, and
1316
+ * the composed picker's `pick` waits on a dialog that cannot open, so the flow
1317
+ * lists folders itself instead of hanging.
1074
1318
  */
1075
1319
  const chooseOnPc = () => {
1076
1320
  if (busy) return
1321
+ const bridge = typeof globalThis === 'undefined' ? undefined : globalThis.__DSH_DIRECTORY_PICKER__
1322
+ if (bridge !== undefined && typeof bridge.pick === 'function') {
1323
+ setBusy(true)
1324
+ setError('')
1325
+ Promise.resolve()
1326
+ .then(() => bridge.pick())
1327
+ .then(path => {
1328
+ if (typeof path === 'string' && path !== '') {
1329
+ land(path)
1330
+ return
1331
+ }
1332
+ setBusy(false)
1333
+ })
1334
+ .catch(reason => fail(messageOf(reason)))
1335
+ return
1336
+ }
1077
1337
  setBusy(true)
1078
1338
  setError('')
1079
1339
  Promise.resolve()
@@ -1189,7 +1449,7 @@ function WorkspaceSourceFlow(props) {
1189
1449
  padding: '6px',
1190
1450
  overflowY: 'auto',
1191
1451
  borderRadius: 'var(--dsw-radius-lg, 12px)',
1192
- background: 'var(--dsw-specific-menu, Canvas)',
1452
+ ...PANEL_SURFACE,
1193
1453
  boxShadow: 'var(--dsw-elevation-prominent, 0 8px 24px rgba(0,0,0,0.18))',
1194
1454
  fontSize: '12px',
1195
1455
  lineHeight: '18px',
@@ -31,6 +31,30 @@ 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
+ ## Signing in and out
35
+
36
+ The session belongs to the GitHub CLI, and both directions stay inside the app:
37
+
38
+ - **Sign in** runs `gh auth login --hostname github.com --git-protocol https
39
+ --web --clipboard --skip-ssh-key` on the Host. With piped stdio that command
40
+ needs no terminal: it prints `! One-time code (XXXX-XXXX) copied to clipboard`
41
+ and `https://github.com/login/device`, then polls GitHub until the code is
42
+ approved. The dialog shows exactly that code with a copy button and the device
43
+ page, polls `loginStatus` every two seconds, and refreshes the repository list
44
+ the moment the CLI reports success. A flow that is never approved is killed
45
+ after five minutes, and cancelling it kills the child immediately; the child
46
+ also dies with the feature, because a pending sign-in is a live process.
47
+ - **Sign out** runs `gh auth logout --hostname github.com --user <login>` after a
48
+ second confirming click, then re-reads the session. "not logged in" counts as
49
+ success, because that is the state the click asked for.
50
+ - **Where you see it** is the sidebar foot: `GitHub · <login>` or
51
+ `GitHub · signed out`, on the same line as the balance. The dialog repeats it,
52
+ and the footer row re-reads on window focus so a `gh auth login` run in a
53
+ terminal shows up without a restart.
54
+
55
+ Nothing about this stores a credential of its own: the code is a one-time device
56
+ code, and the token never leaves the CLI's own credential store.
57
+
34
58
  ## How the entry point works
35
59
 
36
60
  The "+" menu's **Add workspace…** entry only appears when something occupies
@@ -38,7 +62,11 @@ The "+" menu's **Add workspace…** entry only appears when something occupies
38
62
  takes it stands in for the folder picker. This bundle takes it and offers the
39
63
  two sources the owner asked for:
40
64
 
41
- - **On this PC** — a folder browser driven by this bundle's own route
65
+ - **On this PC** — the OS folder dialog. The desktop preload exposes it as
66
+ `window.__DSH_DIRECTORY_PICKER__.pick()`, the same bridge the shipped native
67
+ picker uses, so the flow calls it directly and behaves exactly like the picker
68
+ it stands in for. Where that bridge is absent (the web build) the flow falls
69
+ back to a folder browser driven by this bundle's own route
42
70
  (`api/polish.folders`, actions `roots` and `ls`): roots, a `..` row for going
43
71
  back, directories only, hidden and system entries filtered out, and **Use this
44
72
  folder** hands the current path to the flow's `onPicked`.
@@ -46,15 +74,14 @@ two sources the owner asked for:
46
74
  flow, so a finished clone hands its path back through the flow's `onPicked`,
47
75
  and the Shell creates the workspace exactly as it does for a picked folder.
48
76
 
49
- ### Why the browser does not borrow the Host's chooser
77
+ ### Why the fallback exists at all
50
78
 
51
79
  A composed directory picker serves exactly one capability: `browse` answers
52
80
  `list` and `createDirectory`, `native` answers `pick`. The web profile composes
53
81
  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.
82
+ a dialog that never appears — an earlier build of this flow did exactly that and
83
+ the row spun forever. The preload bridge is the reliable door in the app; the
84
+ listing is what a browser gets instead of a spinner.
58
85
 
59
86
  The tray of the foot keeps the sign-in state (`GitHub · <login>`, or
60
87
  `GitHub · signed out`) next to the balance, so the account is visible without
package/entries/github.js CHANGED
@@ -114,6 +114,189 @@ export async function ghToken(run = runCommand) {
114
114
  return token === '' ? undefined : token
115
115
  }
116
116
 
117
+ /**
118
+ * Pull the one-time code out of what `gh auth login --web` prints.
119
+ *
120
+ * The CLI writes `! One-time code (AE22-76C7) copied to clipboard` to stderr —
121
+ * the dash is the one the browser form accepts, so it is kept verbatim.
122
+ * @param text - captured output.
123
+ * @returns the code, or undefined when the line has not arrived yet.
124
+ */
125
+ export function parseDeviceCode(text) {
126
+ const match = /one-time code \(([A-Z0-9]{4}-[A-Z0-9]{4})\)/i.exec(String(text))
127
+ return match === null ? undefined : match[1].toUpperCase()
128
+ }
129
+
130
+ /**
131
+ * Pull the verification URL out of what `gh auth login --web` prints.
132
+ * @param text - captured output.
133
+ * @returns the URL, defaulting to GitHub's device page.
134
+ */
135
+ export function parseDeviceUrl(text) {
136
+ const match = /(https:\/\/github\.com\/login\/device)/.exec(String(text))
137
+ return match === null ? 'https://github.com/login/device' : match[1]
138
+ }
139
+
140
+ /** The single sign-in this process is running, if any. */
141
+ let loginSession
142
+
143
+ /** Default ceiling on a pending sign-in: the CLI polls until the code is used. */
144
+ export const LOGIN_TIMEOUT_MS = 5 * 60 * 1000
145
+
146
+ /**
147
+ * Start the GitHub CLI's device flow for this machine.
148
+ *
149
+ * `gh auth login --web` needs no terminal: with piped stdio it prints the
150
+ * one-time code and then polls GitHub until the code is approved, which is
151
+ * exactly the shape a dialog can render. The child is kept alive here so the
152
+ * browser can poll for the outcome, and killed when the flow is cancelled,
153
+ * times out, or the feature is disposed.
154
+ * @param options - spawn override plus the timeout.
155
+ * @returns the code and URL to show, or the reason it could not start.
156
+ */
157
+ export function startLogin(options = {}) {
158
+ if (loginSession !== undefined && loginSession.state === 'pending') {
159
+ return { ok: true, value: { state: 'pending', code: loginSession.code, url: loginSession.url } }
160
+ }
161
+ const spawnImpl = options.spawn ?? spawn
162
+ const timeoutMs = options.timeoutMs ?? LOGIN_TIMEOUT_MS
163
+ const session = { state: 'starting', code: undefined, url: 'https://github.com/login/device', reason: '', child: undefined, timer: undefined }
164
+ let text = ''
165
+ const read = chunk => {
166
+ text += chunk.toString()
167
+ const code = parseDeviceCode(text)
168
+ if (code !== undefined && session.code === undefined) {
169
+ session.code = code
170
+ session.url = parseDeviceUrl(text)
171
+ session.state = 'pending'
172
+ }
173
+ }
174
+ try {
175
+ const child = spawnImpl('gh', ['auth', 'login', '--hostname', 'github.com', '--git-protocol', 'https', '--web', '--clipboard', '--skip-ssh-key'], {
176
+ stdio: ['pipe', 'pipe', 'pipe'],
177
+ windowsHide: true,
178
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GH_PROMPT_DISABLED: '' },
179
+ })
180
+ session.child = child
181
+ child.stdout?.setEncoding?.('utf8')
182
+ child.stderr?.setEncoding?.('utf8')
183
+ child.stdout?.on?.('data', read)
184
+ child.stderr?.on?.('data', read)
185
+ child.on?.('error', error => {
186
+ session.state = 'failed'
187
+ session.reason = error instanceof Error ? error.message : 'the GitHub CLI could not be started'
188
+ })
189
+ child.on?.('close', code => {
190
+ if (session.state === 'cancelled') return
191
+ if (code === 0) {
192
+ session.state = 'succeeded'
193
+ session.reason = ''
194
+ return
195
+ }
196
+ session.state = 'failed'
197
+ const parsed = parseDeviceCode(text)
198
+ session.reason = parsed === undefined
199
+ ? 'the GitHub CLI stopped before showing a one-time code'
200
+ : 'the one-time code was not approved'
201
+ })
202
+ session.timer = setTimeout(() => {
203
+ if (session.state !== 'pending' && session.state !== 'starting') return
204
+ session.state = 'failed'
205
+ session.reason = 'the one-time code expired before it was approved'
206
+ session.child?.kill?.()
207
+ }, timeoutMs)
208
+ session.timer?.unref?.()
209
+ loginSession = session
210
+ return { ok: true, value: { state: session.state, code: session.code, url: session.url } }
211
+ } catch (error) {
212
+ session.state = 'failed'
213
+ session.reason = error instanceof Error ? error.message : 'the GitHub CLI could not be started'
214
+ loginSession = session
215
+ return { ok: false, error: session.reason }
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Report the sign-in this process is running.
221
+ * @returns the state, plus the code and URL while it is pending.
222
+ */
223
+ export function loginStatus() {
224
+ if (loginSession === undefined) return { state: 'idle', code: undefined, url: 'https://github.com/login/device', reason: '' }
225
+ return { state: loginSession.state, code: loginSession.code, url: loginSession.url, reason: loginSession.reason }
226
+ }
227
+
228
+ /**
229
+ * Stop a pending sign-in and forget it.
230
+ * @returns the state after cancelling.
231
+ */
232
+ export function cancelLogin() {
233
+ if (loginSession === undefined) return { state: 'idle', code: undefined, url: 'https://github.com/login/device', reason: '' }
234
+ if (loginSession.timer !== undefined) clearTimeout(loginSession.timer)
235
+ try {
236
+ loginSession.child?.kill?.()
237
+ } catch {
238
+ // A child that already exited is the normal case here.
239
+ }
240
+ loginSession.state = 'cancelled'
241
+ loginSession.reason = 'sign-in was cancelled'
242
+ return { state: loginSession.state, code: loginSession.code, url: loginSession.url, reason: loginSession.reason }
243
+ }
244
+
245
+ /**
246
+ * Sign the GitHub CLI out, which is what this feature's sign-in state reads.
247
+ * @param login - the account to remove; the CLI prompts without it.
248
+ * @param run - command runner, injected by tests.
249
+ * @returns ok when the account is gone (including when it was never there).
250
+ */
251
+ export async function signOut(login, run = runCommand) {
252
+ const args = ['auth', 'logout', '--hostname', 'github.com']
253
+ if (typeof login === 'string' && login !== '') args.push('--user', login)
254
+ const answer = await run('gh', args)
255
+ if (answer.code === 0) return { ok: true }
256
+ const text = `${answer.stdout}${answer.stderr}`.trim()
257
+ if (/not logged in/i.test(text)) return { ok: true }
258
+ return { ok: false, error: text === '' ? 'the GitHub CLI could not sign out' : text }
259
+ }
260
+
261
+ /**
262
+ * Read every account the GitHub CLI holds for github.com.
263
+ *
264
+ * A machine can carry more than one account in the CLI keyring, and `gh auth
265
+ * logout --user` only removes the one it names — the CLI then makes another one
266
+ * active, which is why a sign-out can appear to do nothing.
267
+ *
268
+ * @param run - command runner, injected by tests.
269
+ * @returns the accounts in the CLI's own order, the active one first when known.
270
+ */
271
+ export async function listAccounts(run = runCommand) {
272
+ const answer = await run('gh', ['auth', 'status'])
273
+ const text = `${answer.stdout}${answer.stderr}`
274
+ const logins = []
275
+ const pattern = /Logged in to github\.com account ([^\s]+)/g
276
+ let match = pattern.exec(text)
277
+ while (match !== null) {
278
+ if (logins.indexOf(match[1]) === -1) logins.push(match[1])
279
+ match = pattern.exec(text)
280
+ }
281
+ return logins
282
+ }
283
+
284
+ /**
285
+ * Sign every account out of the GitHub CLI.
286
+ * @param logins - accounts to remove.
287
+ * @param run - command runner, injected by tests.
288
+ * @returns ok when no account is left, else the first failure.
289
+ */
290
+ export async function signOutAll(logins, run = runCommand) {
291
+ const removed = []
292
+ for (const login of logins) {
293
+ const answer = await signOut(login, run)
294
+ if (answer.ok !== true) return { ok: false, error: answer.error, removed }
295
+ removed.push(login)
296
+ }
297
+ return { ok: true, removed }
298
+ }
299
+
117
300
  /**
118
301
  * Read the login that owns a token.
119
302
  * @param token - GitHub access token.
@@ -257,19 +440,49 @@ export async function cloneRepo(owner, name, token, options = {}) {
257
440
  */
258
441
  export async function handleRequest(body, deps = {}) {
259
442
  const action = body?.action
443
+ // Sign-in is reachable while signed out: it is how a machine gets there.
444
+ if (action === 'login') return (deps.startLogin ?? startLogin)()
445
+ if (action === 'loginStatus') return { ok: true, value: (deps.loginStatus ?? loginStatus)() }
446
+ if (action === 'loginCancel') return { ok: true, value: (deps.cancelLogin ?? cancelLogin)() }
447
+ // Signing everything out is reachable while signed in, so it belongs above the
448
+ // token gate together with the sign-in verbs.
449
+ if (action === 'logoutAll') {
450
+ const accounts = await (deps.accounts ?? listAccounts)()
451
+ if (accounts.length === 0) return { ok: true, value: { logins: [] } }
452
+ const answer = await (deps.signOutAll ?? signOutAll)(accounts)
453
+ if (answer.ok !== true) return { ok: false, error: answer.error }
454
+ return { ok: true, value: { logins: answer.removed } }
455
+ }
260
456
  const token = await (deps.token ?? ghToken)()
261
457
  if (action === 'status') {
458
+ const accounts = await (deps.accounts ?? listAccounts)().catch(() => [])
262
459
  if (token === undefined) {
263
460
  return {
264
461
  ok: true,
265
- value: { signedIn: false, login: '', root: cloneRoot(deps.home), reason: 'The GitHub CLI is missing or not signed in: run `gh auth login` in a terminal.' },
462
+ value: {
463
+ signedIn: false,
464
+ login: '',
465
+ accounts: [],
466
+ root: cloneRoot(deps.home),
467
+ reason: 'The GitHub CLI is not signed in on this machine. Sign in from this dialog, or run `gh auth login` in a terminal.',
468
+ },
266
469
  }
267
470
  }
268
471
  const login = await (deps.login ?? fetchLogin)(token)
269
- return { ok: true, value: { signedIn: true, login, root: cloneRoot(deps.home), reason: '' } }
472
+ return { ok: true, value: { signedIn: true, login, accounts, root: cloneRoot(deps.home), reason: '' } }
270
473
  }
271
474
  if (token === undefined) {
272
- return { ok: false, error: 'Sign in with the GitHub CLI first: run `gh auth login` in a terminal, then try again.' }
475
+ return { ok: false, error: 'Sign in to GitHub first: use Sign in here, or run `gh auth login` in a terminal.' }
476
+ }
477
+ if (action === 'logout') {
478
+ const login = await (deps.login ?? fetchLogin)(token)
479
+ const answer = await (deps.signOut ?? signOut)(login)
480
+ if (answer.ok !== true) return { ok: false, error: answer.error }
481
+ // The CLI may fall back to another account in its keyring; report which one,
482
+ // so the dialog can say what is signed in now instead of going silent.
483
+ const after = await (deps.token ?? ghToken)().catch(() => undefined)
484
+ const remaining = after === undefined ? '' : await (deps.login ?? fetchLogin)(after).catch(() => '')
485
+ return { ok: true, value: { login, remaining } }
273
486
  }
274
487
  if (action === 'repos') {
275
488
  const repos = await (deps.repos ?? fetchRepos)(token)
@@ -307,6 +520,8 @@ export const githubFeature = {
307
520
  name: 'polish-github',
308
521
  inject: ['connection'],
309
522
  apply(ctx, config = {}) {
523
+ // A pending device flow holds a child process: it must die with the feature.
524
+ ctx.effect(() => () => { cancelLogin() })
310
525
  ctx.effect(() => ctx.connection.fetch.register({
311
526
  path: GITHUB_ROUTE,
312
527
  // Without a method list the platform would only route GET, which is why the
package/lib/client.js CHANGED
@@ -41,7 +41,7 @@ window.__ModuleLoader__.load({
41
41
  ]
42
42
  const ROW_ID = "polish"
43
43
  const ROW_KEY = "@aryacode/dsh-aryacode-polish" + '#' + ROW_ID
44
- const CLIENT_VERSION = "0.6.0"
44
+ const CLIENT_VERSION = "0.7.3"
45
45
 
46
46
  /**
47
47
  * The two pure decisions behind the balance line: which wallet to show, and how
@@ -419,6 +419,24 @@ window.__ModuleLoader__.load({
419
419
  /** The label half of the pill, cut with an ellipsis rather than wrapped. */
420
420
  const PILL_LABEL_STYLE = { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }
421
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
+
422
440
  /** The dialog surface, copied from the harness `stat-dialog.module.css` skin. */
423
441
  const PANEL_STYLE = {
424
442
  position: 'fixed',
@@ -428,10 +446,8 @@ window.__ModuleLoader__.load({
428
446
  minWidth: 'min(300px, calc(100vw - 24px))',
429
447
  maxWidth: 'min(440px, calc(100vw - 24px))',
430
448
  padding: '16px',
431
- border: 0,
432
449
  borderRadius: 'var(--dsw-radius-lg, 12px)',
433
- background: 'var(--dsw-specific-menu, Canvas)',
434
- backdropFilter: 'var(--dsw-menu-backdrop-filter, none)',
450
+ ...PANEL_SURFACE,
435
451
  boxShadow: 'var(--dsw-elevation-prominent, 0 10px 30px rgba(0, 0, 0, 0.18))',
436
452
  fontSize: '12px',
437
453
  lineHeight: '18px',
@@ -829,6 +845,29 @@ window.__ModuleLoader__.load({
829
845
  }
830
846
  })()
831
847
 
848
+ /**
849
+ * The last GitHub status anyone read, shared by the dialog and the footer line.
850
+ *
851
+ * The footer used to re-read only on window focus, so signing out in the dialog
852
+ * left `GitHub · KonnectSuite` on screen while the CLI was already signed out (or
853
+ * signed in as another account). One store, written by every read, keeps them equal.
854
+ */
855
+ const githubStatusStore = (() => {
856
+ let value
857
+ const listeners = new Set()
858
+ return {
859
+ get: () => value,
860
+ subscribe(listener) {
861
+ listeners.add(listener)
862
+ return () => { listeners.delete(listener) }
863
+ },
864
+ set(next) {
865
+ value = next
866
+ for (const listener of listeners) listener()
867
+ },
868
+ }
869
+ })()
870
+
832
871
  /**
833
872
  * Post one action to the GitHub route.
834
873
  * @param action - `status`, `repos` or `clone`.
@@ -898,6 +937,20 @@ window.__ModuleLoader__.load({
898
937
  borderTop: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.12))',
899
938
  }
900
939
 
940
+ /** A secondary button in the sign-in block, sized like the harness's own. */
941
+ const GITHUB_BUTTON_STYLE = {
942
+ display: 'inline-flex',
943
+ alignItems: 'center',
944
+ justifyContent: 'center',
945
+ padding: '4px 10px',
946
+ border: '0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.16))',
947
+ borderRadius: 'var(--dsw-radius-sm, 6px)',
948
+ background: 'transparent',
949
+ color: 'inherit',
950
+ font: 'inherit',
951
+ cursor: 'pointer',
952
+ }
953
+
901
954
  const GITHUB_ROW_STYLE = {
902
955
  display: 'flex',
903
956
  alignItems: 'center',
@@ -913,8 +966,7 @@ window.__ModuleLoader__.load({
913
966
  borderBottom: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.08))',
914
967
  }
915
968
 
916
- const GITHUB_INPUT_STYLE = {
917
- width: '100%',
969
+ const GITHUB_INPUT_STYLE = { width: '100%',
918
970
  boxSizing: 'border-box',
919
971
  padding: '6px 8px',
920
972
  font: 'inherit',
@@ -944,6 +996,33 @@ window.__ModuleLoader__.load({
944
996
  const [query, setQuery] = React.useState('')
945
997
  const [error, setError] = React.useState('')
946
998
  const [busy, setBusy] = React.useState('')
999
+ const [signIn, setSignIn] = React.useState({ state: 'idle', code: '', url: '', reason: '' })
1000
+ const [confirmOut, setConfirmOut] = React.useState(false)
1001
+ const [confirmAll, setConfirmAll] = React.useState(false)
1002
+ const [notice, setNotice] = React.useState('')
1003
+ const [copied, setCopied] = React.useState(false)
1004
+
1005
+ /** Put the one-time code on the clipboard, so it can be pasted in the browser. */
1006
+ const copyCode = () => {
1007
+ const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard
1008
+ if (clipboard === undefined || typeof clipboard.writeText !== 'function') return
1009
+ Promise.resolve(clipboard.writeText(signIn.code))
1010
+ .then(() => {
1011
+ setCopied(true)
1012
+ setTimeout(() => setCopied(false), 2000)
1013
+ })
1014
+ .catch(() => setCopied(false))
1015
+ }
1016
+
1017
+ /** Re-read the session, and the repository list when it is signed in. */
1018
+ const refresh = () => Promise.resolve()
1019
+ .then(() => githubCall('status'))
1020
+ .then(status => {
1021
+ setAccount(status)
1022
+ githubStatusStore.set(status)
1023
+ if (status.signedIn !== true) return undefined
1024
+ return githubCall('repos').then(list => setRepos(Array.isArray(list.repos) ? list.repos : []))
1025
+ })
947
1026
 
948
1027
  React.useEffect(() => {
949
1028
  if (!open || !enabled) return undefined
@@ -952,17 +1031,9 @@ window.__ModuleLoader__.load({
952
1031
  setError('')
953
1032
  setBusy('')
954
1033
  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
- })
1034
+ setConfirmOut(false)
1035
+ setSignIn({ state: 'idle', code: '', url: '', reason: '' })
1036
+ refresh()
966
1037
  .then(() => { if (live) setPhase('ready') })
967
1038
  .catch(reason => {
968
1039
  if (!live) return
@@ -972,6 +1043,29 @@ window.__ModuleLoader__.load({
972
1043
  return () => { live = false }
973
1044
  }, [open, enabled])
974
1045
 
1046
+ // The device flow is a long poll: ask the Host what the CLI child reports.
1047
+ // `starting` has to be polled too — the Host answers `starting` the moment the
1048
+ // child is spawned and only reports the code a tick later, so polling on
1049
+ // `pending` alone left the dialog asking for a code that had already arrived.
1050
+ React.useEffect(() => {
1051
+ if (!open || (signIn.state !== 'pending' && signIn.state !== 'starting')) return undefined
1052
+ const timer = setInterval(() => {
1053
+ Promise.resolve()
1054
+ .then(() => githubCall('loginStatus'))
1055
+ .then(answer => {
1056
+ setSignIn({ state: answer.state, code: answer.code ?? '', url: answer.url ?? '', reason: answer.reason ?? '' })
1057
+ if (answer.state === 'succeeded') {
1058
+ setPhase('loading')
1059
+ return refresh().then(() => setPhase('ready'))
1060
+ }
1061
+ if (answer.state === 'failed' || answer.state === 'cancelled') setError(answer.reason ?? 'Sign-in did not finish.')
1062
+ return undefined
1063
+ })
1064
+ .catch(reason => setError(reason instanceof Error ? reason.message : String(reason)))
1065
+ }, 2000)
1066
+ return () => clearInterval(timer)
1067
+ }, [open, signIn.state])
1068
+
975
1069
  React.useEffect(() => {
976
1070
  if (!open) return undefined
977
1071
  const onKey = event => { if (event.key === 'Escape') githubStore.set(false) }
@@ -1016,6 +1110,78 @@ window.__ModuleLoader__.load({
1016
1110
  })
1017
1111
  }
1018
1112
 
1113
+ /**
1114
+ * Start the CLI's device flow. The Host runs it, so this dialog only renders
1115
+ * the code the CLI printed and the page it wants opened.
1116
+ */
1117
+ const beginSignIn = () => {
1118
+ setError('')
1119
+ setSignIn({ state: 'starting', code: '', url: '', reason: '' })
1120
+ Promise.resolve()
1121
+ .then(() => githubCall('login'))
1122
+ .then(answer => setSignIn({
1123
+ state: answer.state ?? 'failed',
1124
+ code: answer.code ?? '',
1125
+ url: answer.url ?? '',
1126
+ reason: answer.reason ?? '',
1127
+ }))
1128
+ .catch(reason => {
1129
+ setSignIn({ state: 'failed', code: '', url: '', reason: '' })
1130
+ setError(reason instanceof Error ? reason.message : String(reason))
1131
+ })
1132
+ }
1133
+
1134
+ const cancelSignIn = () => {
1135
+ Promise.resolve()
1136
+ .then(() => githubCall('loginCancel'))
1137
+ .then(() => setSignIn({ state: 'idle', code: '', url: '', reason: '' }))
1138
+ .catch(() => setSignIn({ state: 'idle', code: '', url: '', reason: '' }))
1139
+ }
1140
+
1141
+ /** Sign the CLI out, which is what every read in this feature depends on. */
1142
+ const signOutNow = () => {
1143
+ setConfirmOut(false)
1144
+ setBusy('sign-out')
1145
+ setError('')
1146
+ setNotice('')
1147
+ Promise.resolve()
1148
+ .then(() => githubCall('logout'))
1149
+ .then(answer => {
1150
+ const remaining = typeof answer.remaining === 'string' ? answer.remaining : ''
1151
+ setAccount(undefined)
1152
+ setRepos([])
1153
+ setSignIn({ state: 'idle', code: '', url: '', reason: '' })
1154
+ setNotice(remaining === ''
1155
+ ? `Signed out of ${String(answer.login)}.`
1156
+ : `Signed out of ${String(answer.login)}. The GitHub CLI is now signed in as ${remaining}.`)
1157
+ return refresh()
1158
+ })
1159
+ .catch(reason => setError(reason instanceof Error ? reason.message : String(reason)))
1160
+ .then(() => setBusy(''))
1161
+ }
1162
+
1163
+ /** Sign every account in the CLI's keyring out, so no silent fallback is left. */
1164
+ const signOutAllNow = () => {
1165
+ setConfirmAll(false)
1166
+ setBusy('sign-out-all')
1167
+ setError('')
1168
+ setNotice('')
1169
+ Promise.resolve()
1170
+ .then(() => githubCall('logoutAll'))
1171
+ .then(answer => {
1172
+ const logins = Array.isArray(answer.logins) ? answer.logins.map(String) : []
1173
+ setAccount(undefined)
1174
+ setRepos([])
1175
+ setSignIn({ state: 'idle', code: '', url: '', reason: '' })
1176
+ setNotice(logins.length === 0
1177
+ ? 'No GitHub account was signed in.'
1178
+ : `Signed out of ${logins.join(', ')}.`)
1179
+ return refresh()
1180
+ })
1181
+ .catch(reason => setError(reason instanceof Error ? reason.message : String(reason)))
1182
+ .then(() => setBusy(''))
1183
+ }
1184
+
1019
1185
  const rows = visible.slice(0, 200).map(repo => React.createElement('button', {
1020
1186
  key: repo.fullName,
1021
1187
  type: 'button',
@@ -1043,18 +1209,45 @@ window.__ModuleLoader__.load({
1043
1209
  React.createElement('div', { style: GITHUB_CARD_STYLE, role: 'dialog', 'aria-label': 'Add a workspace from GitHub' },
1044
1210
  React.createElement('div', { style: { ...PANEL_TITLE_STYLE, marginBottom: '4px' } },
1045
1211
  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
- }, '×')),
1212
+ React.createElement('span', { style: { display: 'flex', alignItems: 'center', gap: '10px' } },
1213
+ signedIn && (Array.isArray(account?.accounts) ? account.accounts.length : 0) > 1
1214
+ ? React.createElement('button', {
1215
+ type: 'button',
1216
+ 'data-polish-github': 'signoutall',
1217
+ disabled: busy === 'sign-out-all',
1218
+ onClick: () => { if (confirmAll) signOutAllNow(); else setConfirmAll(true) },
1219
+ title: 'Sign every GitHub account in the CLI out of this machine',
1220
+ style: { border: 'none', background: 'transparent', color: 'inherit', font: 'inherit', cursor: 'pointer', opacity: 0.7, padding: 0 },
1221
+ }, busy === 'sign-out-all' ? 'Signing out…' : confirmAll ? 'Sign out all now?' : 'Sign out all')
1222
+ : null,
1223
+ signedIn
1224
+ ? React.createElement('button', {
1225
+ type: 'button',
1226
+ 'data-polish-github': 'signout',
1227
+ disabled: busy === 'sign-out',
1228
+ onClick: () => { if (confirmOut) signOutNow(); else setConfirmOut(true) },
1229
+ title: 'Sign the GitHub CLI out of this machine',
1230
+ style: { border: 'none', background: 'transparent', color: 'inherit', font: 'inherit', cursor: 'pointer', opacity: 0.7, padding: 0 },
1231
+ }, busy === 'sign-out' ? 'Signing out…' : confirmOut ? 'Sign out now?' : 'Sign out')
1232
+ : null,
1233
+ React.createElement('button', {
1234
+ type: 'button',
1235
+ onClick: () => githubStore.set(false),
1236
+ style: { border: 'none', background: 'transparent', color: 'inherit', font: 'inherit', cursor: 'pointer', opacity: 0.7 },
1237
+ 'aria-label': 'Close',
1238
+ }, '×'))),
1052
1239
  React.createElement('div', { style: { opacity: 0.75, minHeight: '18px' } },
1053
1240
  phase === 'loading' && !signedIn ? 'Checking the GitHub CLI session…' : null,
1054
1241
  signedIn ? `Signed in as ${String(account.login)} · cloning into ${root}` : null,
1055
1242
  !signedIn && phase !== 'loading'
1056
- ? 'The GitHub CLI is not signed in. Run `gh auth login` in a terminal, then reopen this dialog.'
1243
+ ? 'GitHub is not signed in on this machine. The sign-in below is the GitHub CLI device flow, so no token is ever typed here or stored by this plugin.'
1057
1244
  : null),
1245
+ notice === ''
1246
+ ? null
1247
+ : React.createElement('div', {
1248
+ 'data-polish-github': 'notice',
1249
+ style: { marginTop: '6px', opacity: 0.85 },
1250
+ }, notice),
1058
1251
  signedIn
1059
1252
  ? React.createElement('input', {
1060
1253
  style: { ...GITHUB_INPUT_STYLE, marginTop: '10px' },
@@ -1068,6 +1261,48 @@ window.__ModuleLoader__.load({
1068
1261
  error !== ''
1069
1262
  ? React.createElement('div', { style: { marginTop: '10px', color: 'var(--dsw-alias-label-error, #b42318)' } }, error)
1070
1263
  : null,
1264
+ !signedIn && phase !== 'loading'
1265
+ ? React.createElement('div', { style: { marginTop: '12px' } },
1266
+ signIn.state === 'pending' || signIn.state === 'starting'
1267
+ ? React.createElement('div', null,
1268
+ React.createElement('div', { style: { marginBottom: '8px' } },
1269
+ signIn.state === 'starting' ? 'Asking the GitHub CLI for a code…' : 'Approve this one-time code in your browser:'),
1270
+ signIn.code === ''
1271
+ ? null
1272
+ : React.createElement('div', {
1273
+ 'data-polish-github': 'code',
1274
+ style: {
1275
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
1276
+ fontSize: '18px',
1277
+ letterSpacing: '2px',
1278
+ padding: '8px 10px',
1279
+ borderRadius: 'var(--dsw-radius-sm, 6px)',
1280
+ border: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.12))',
1281
+ },
1282
+ }, signIn.code),
1283
+ React.createElement('div', { style: { display: 'flex', gap: '8px', marginTop: '10px', flexWrap: 'wrap' } },
1284
+ React.createElement('button', { type: 'button', onClick: copyCode, style: GITHUB_BUTTON_STYLE, 'data-polish-github': 'copy' },
1285
+ copied ? 'Copied' : 'Copy code'),
1286
+ React.createElement('a', {
1287
+ href: signIn.url === '' ? 'https://github.com/login/device' : signIn.url,
1288
+ target: '_blank',
1289
+ rel: 'noreferrer',
1290
+ style: { ...GITHUB_BUTTON_STYLE, textDecoration: 'none', display: 'inline-flex', alignItems: 'center' },
1291
+ }, 'Open the device page'),
1292
+ React.createElement('button', { type: 'button', onClick: cancelSignIn, style: GITHUB_BUTTON_STYLE, 'data-polish-github': 'cancel' }, 'Cancel')))
1293
+ : React.createElement('div', null,
1294
+ React.createElement('button', {
1295
+ type: 'button',
1296
+ onClick: beginSignIn,
1297
+ // The attribute belongs on the element: inside `style` it is ignored,
1298
+ // which is exactly why the first verification could not find this button.
1299
+ 'data-polish-github': 'signin',
1300
+ style: GITHUB_BUTTON_STYLE,
1301
+ }, 'Sign in with GitHub'),
1302
+ signIn.state === 'failed' && signIn.reason !== ''
1303
+ ? React.createElement('div', { style: { marginTop: '8px', color: 'var(--dsw-alias-label-error, #b42318)' } }, signIn.reason)
1304
+ : null))
1305
+ : null,
1071
1306
  signedIn
1072
1307
  ? React.createElement('div', { style: GITHUB_LIST_STYLE, 'data-polish-github-list': String(visible.length) },
1073
1308
  phase === 'loading' && visible.length === 0 ? React.createElement('div', { style: { padding: '10px 4px', opacity: 0.7 } }, 'Loading repositories…') : null,
@@ -1112,6 +1347,9 @@ window.__ModuleLoader__.load({
1112
1347
  */
1113
1348
  function GithubStatus(props) {
1114
1349
  const enabled = useSwitch(props.configForms, 'githubWorkspace', true)
1350
+ // The dialog publishes every status it reads, so a sign-out in the dialog lands
1351
+ // in this line immediately instead of waiting for the next window focus.
1352
+ const shared = React.useSyncExternalStore(githubStatusStore.subscribe, githubStatusStore.get)
1115
1353
  const [status, setStatus] = React.useState(undefined)
1116
1354
  React.useEffect(() => {
1117
1355
  if (!enabled) return undefined
@@ -1119,7 +1357,11 @@ window.__ModuleLoader__.load({
1119
1357
  const read = () => {
1120
1358
  Promise.resolve()
1121
1359
  .then(() => githubCall('status'))
1122
- .then(next => { if (live) setStatus(next) })
1360
+ .then(next => {
1361
+ if (!live) return
1362
+ setStatus(next)
1363
+ githubStatusStore.set(next)
1364
+ })
1123
1365
  .catch(() => { if (live) setStatus({ signedIn: false }) })
1124
1366
  }
1125
1367
  read()
@@ -1131,15 +1373,16 @@ window.__ModuleLoader__.load({
1131
1373
  }
1132
1374
  }, [enabled])
1133
1375
  if (!enabled) return null
1134
- const signedIn = status !== undefined && status.signedIn === true
1135
- const label = status === undefined
1376
+ const current = shared === undefined ? status : shared
1377
+ const signedIn = current !== undefined && current.signedIn === true
1378
+ const label = current === undefined
1136
1379
  ? 'GitHub · checking…'
1137
- : signedIn ? `GitHub · ${String(status.login)}` : 'GitHub · signed out'
1380
+ : signedIn ? `GitHub · ${String(current.login)}` : 'GitHub · signed out'
1138
1381
  return React.createElement('div', { style: { padding: '2px 8px 0', minWidth: 0 } },
1139
1382
  React.createElement('button', {
1140
1383
  type: 'button',
1141
1384
  'data-polish-github': 'status',
1142
- 'data-polish-github-state': signedIn ? 'in' : status === undefined ? 'checking' : 'out',
1385
+ 'data-polish-github-state': signedIn ? 'in' : current === undefined ? 'checking' : 'out',
1143
1386
  title: signedIn
1144
1387
  ? 'Add a workspace from GitHub'
1145
1388
  : 'Sign in with the GitHub CLI (gh auth login), then add a repository as a workspace',
@@ -1231,16 +1474,33 @@ window.__ModuleLoader__.load({
1231
1474
  }
1232
1475
 
1233
1476
  /**
1234
- * Start the folder browser at the machine's roots.
1477
+ * Hand folder choice to the Host.
1235
1478
  *
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`.
1479
+ * In the desktop app the preload exposes the real OS chooser as
1480
+ * `__DSH_DIRECTORY_PICKER__.pick()` — the same bridge the shipped native
1481
+ * picker uses — so the flow opens the Windows folder dialog and behaves like
1482
+ * the stock picker it stands in for. In a browser there is no such bridge, and
1483
+ * the composed picker's `pick` waits on a dialog that cannot open, so the flow
1484
+ * lists folders itself instead of hanging.
1241
1485
  */
1242
1486
  const chooseOnPc = () => {
1243
1487
  if (busy) return
1488
+ const bridge = typeof globalThis === 'undefined' ? undefined : globalThis.__DSH_DIRECTORY_PICKER__
1489
+ if (bridge !== undefined && typeof bridge.pick === 'function') {
1490
+ setBusy(true)
1491
+ setError('')
1492
+ Promise.resolve()
1493
+ .then(() => bridge.pick())
1494
+ .then(path => {
1495
+ if (typeof path === 'string' && path !== '') {
1496
+ land(path)
1497
+ return
1498
+ }
1499
+ setBusy(false)
1500
+ })
1501
+ .catch(reason => fail(messageOf(reason)))
1502
+ return
1503
+ }
1244
1504
  setBusy(true)
1245
1505
  setError('')
1246
1506
  Promise.resolve()
@@ -1356,7 +1616,7 @@ window.__ModuleLoader__.load({
1356
1616
  padding: '6px',
1357
1617
  overflowY: 'auto',
1358
1618
  borderRadius: 'var(--dsw-radius-lg, 12px)',
1359
- background: 'var(--dsw-specific-menu, Canvas)',
1619
+ ...PANEL_SURFACE,
1360
1620
  boxShadow: 'var(--dsw-elevation-prominent, 0 8px 24px rgba(0,0,0,0.18))',
1361
1621
  fontSize: '12px',
1362
1622
  lineHeight: '18px',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aryacode/dsh-aryacode-polish",
3
- "version": "0.6.0",
3
+ "version": "0.7.3",
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",