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