@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 +49 -0
- package/client/face.js +295 -35
- package/docs/github-workspaces.md +33 -6
- package/entries/github.js +218 -3
- package/lib/client.js +296 -36
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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('
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
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
|
-
? '
|
|
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 => {
|
|
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
|
|
968
|
-
const
|
|
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(
|
|
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' :
|
|
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
|
-
*
|
|
1310
|
+
* Hand folder choice to the Host.
|
|
1068
1311
|
*
|
|
1069
|
-
*
|
|
1070
|
-
*
|
|
1071
|
-
*
|
|
1072
|
-
*
|
|
1073
|
-
*
|
|
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
|
-
|
|
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** —
|
|
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
|
|
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 —
|
|
55
|
-
the row spun forever.
|
|
56
|
-
|
|
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: {
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
956
|
-
|
|
957
|
-
|
|
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('
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
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
|
-
? '
|
|
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 => {
|
|
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
|
|
1135
|
-
const
|
|
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(
|
|
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' :
|
|
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
|
-
*
|
|
1477
|
+
* Hand folder choice to the Host.
|
|
1235
1478
|
*
|
|
1236
|
-
*
|
|
1237
|
-
*
|
|
1238
|
-
*
|
|
1239
|
-
*
|
|
1240
|
-
*
|
|
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
|
-
|
|
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.
|
|
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",
|