@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/client/face.js
CHANGED
|
@@ -466,6 +466,7 @@ function CostPill(props) {
|
|
|
466
466
|
*/
|
|
467
467
|
const BALANCE_RETRY_DELAYS = [1500, 5000, 15000, 30000]
|
|
468
468
|
|
|
469
|
+
|
|
469
470
|
/**
|
|
470
471
|
* The remaining-balance line in the sidebar foot.
|
|
471
472
|
*
|
|
@@ -530,22 +531,24 @@ function BalanceLine(props) {
|
|
|
530
531
|
return React.createElement('div', { 'data-polish-balance': 'empty', style: { display: 'none' } })
|
|
531
532
|
}
|
|
532
533
|
const money = formatBalance(wallet)
|
|
534
|
+
// The foot is a single row shared with the GitHub entry, so the amount stays
|
|
535
|
+
// bare: a tooltip names it, and nothing here competes for width.
|
|
533
536
|
return React.createElement('div', {
|
|
534
537
|
title: 'Remaining account balance',
|
|
538
|
+
'data-polish-balance': 'ready',
|
|
535
539
|
style: {
|
|
536
540
|
display: 'flex',
|
|
537
541
|
alignItems: 'center',
|
|
538
542
|
gap: '6px',
|
|
539
|
-
padding: '2px
|
|
543
|
+
padding: '2px 4px 4px 8px',
|
|
540
544
|
fontSize: '12px',
|
|
541
545
|
lineHeight: '16px',
|
|
542
|
-
|
|
543
|
-
overflow: 'hidden',
|
|
546
|
+
flex: '0 0 auto',
|
|
544
547
|
whiteSpace: 'nowrap',
|
|
545
|
-
textOverflow: 'ellipsis',
|
|
546
548
|
opacity: 0.75,
|
|
549
|
+
fontVariantNumeric: 'tabular-nums',
|
|
547
550
|
},
|
|
548
|
-
},
|
|
551
|
+
}, money)
|
|
549
552
|
}
|
|
550
553
|
|
|
551
554
|
/**
|
|
@@ -616,6 +619,604 @@ function OpenFolderMenuItem(props) {
|
|
|
616
619
|
return React.createElement('button', { type: 'button', role: 'menuitem', onClick: onSelect }, label)
|
|
617
620
|
}
|
|
618
621
|
|
|
622
|
+
/** The Host route the GitHub halves talk over. Document-relative, like every app route. */
|
|
623
|
+
const GITHUB_ROUTE = 'api/polish.github'
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Whether the GitHub panel is open. One store for the whole bundle: the footer
|
|
627
|
+
* row opens it, and the overlay panel is the only reader.
|
|
628
|
+
*/
|
|
629
|
+
const githubStore = (() => {
|
|
630
|
+
let open = false
|
|
631
|
+
// Set when the dialog was opened from the add-workspace flow: the flow owns
|
|
632
|
+
// workspace creation through `onPicked`, so a clone hands it the path rather
|
|
633
|
+
// than calling the Workspace service itself.
|
|
634
|
+
let picker
|
|
635
|
+
const listeners = new Set()
|
|
636
|
+
const publish = () => { for (const listener of listeners) listener() }
|
|
637
|
+
return {
|
|
638
|
+
isOpen: () => open,
|
|
639
|
+
subscribe(listener) {
|
|
640
|
+
listeners.add(listener)
|
|
641
|
+
return () => { listeners.delete(listener) }
|
|
642
|
+
},
|
|
643
|
+
set(next) {
|
|
644
|
+
if (open === next) return
|
|
645
|
+
open = next
|
|
646
|
+
if (!next) picker = undefined
|
|
647
|
+
publish()
|
|
648
|
+
},
|
|
649
|
+
/** Open the dialog, optionally on behalf of the add-workspace flow. */
|
|
650
|
+
open(onPicked) {
|
|
651
|
+
picker = onPicked
|
|
652
|
+
if (open) return
|
|
653
|
+
open = true
|
|
654
|
+
publish()
|
|
655
|
+
},
|
|
656
|
+
/** Take the flow's `onPicked` once, so a second clone cannot reuse it. */
|
|
657
|
+
takePicker() {
|
|
658
|
+
const current = picker
|
|
659
|
+
picker = undefined
|
|
660
|
+
return current
|
|
661
|
+
},
|
|
662
|
+
}
|
|
663
|
+
})()
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Post one action to the GitHub route.
|
|
667
|
+
* @param action - `status`, `repos` or `clone`.
|
|
668
|
+
* @param extra - fields the action needs.
|
|
669
|
+
* @returns the route's `value`, or throws with its own message.
|
|
670
|
+
*/
|
|
671
|
+
async function githubCall(action, extra) {
|
|
672
|
+
const response = await fetch(GITHUB_ROUTE, {
|
|
673
|
+
method: 'POST',
|
|
674
|
+
headers: { 'content-type': 'application/json' },
|
|
675
|
+
body: JSON.stringify({ action, ...(extra ?? {}) }),
|
|
676
|
+
})
|
|
677
|
+
const payload = await response.json().catch(() => undefined)
|
|
678
|
+
if (payload === undefined || typeof payload !== 'object') {
|
|
679
|
+
throw new Error(`the Host answered ${String(response.status)}`)
|
|
680
|
+
}
|
|
681
|
+
if (payload.ok !== true) throw new Error(payload.error || 'GitHub request failed')
|
|
682
|
+
return payload.value
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** The Host route the folder browser talks over. Document-relative, like every app route. */
|
|
686
|
+
const FOLDERS_ROUTE = 'api/polish.folders'
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Post one action to the folder route.
|
|
690
|
+
* @param body - `{ action: 'roots' }` or `{ action: 'ls', path }`.
|
|
691
|
+
* @returns the route's listing, or throws with its own message.
|
|
692
|
+
*/
|
|
693
|
+
async function foldersCall(body) {
|
|
694
|
+
const response = await fetch(FOLDERS_ROUTE, {
|
|
695
|
+
method: 'POST',
|
|
696
|
+
headers: { 'content-type': 'application/json' },
|
|
697
|
+
body: JSON.stringify(body),
|
|
698
|
+
})
|
|
699
|
+
const payload = await response.json().catch(() => undefined)
|
|
700
|
+
if (payload === undefined || typeof payload !== 'object') {
|
|
701
|
+
throw new Error(`the Host answered ${String(response.status)}`)
|
|
702
|
+
}
|
|
703
|
+
if (payload.error !== undefined) throw new Error(String(payload.error))
|
|
704
|
+
return payload
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** Modal backdrop and panel skin, matching the Shell's own dialogs. */
|
|
708
|
+
const GITHUB_BACKDROP_STYLE = {
|
|
709
|
+
position: 'fixed',
|
|
710
|
+
inset: 0,
|
|
711
|
+
zIndex: 1200,
|
|
712
|
+
display: 'flex',
|
|
713
|
+
alignItems: 'center',
|
|
714
|
+
justifyContent: 'center',
|
|
715
|
+
background: 'rgba(16, 18, 22, 0.32)',
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const GITHUB_CARD_STYLE = {
|
|
719
|
+
...PANEL_STYLE,
|
|
720
|
+
position: 'static',
|
|
721
|
+
width: 'min(560px, calc(100vw - 32px))',
|
|
722
|
+
maxHeight: 'min(560px, calc(100vh - 64px))',
|
|
723
|
+
display: 'flex',
|
|
724
|
+
flexDirection: 'column',
|
|
725
|
+
overflow: 'hidden',
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const GITHUB_LIST_STYLE = {
|
|
729
|
+
marginTop: '10px',
|
|
730
|
+
overflowY: 'auto',
|
|
731
|
+
borderTop: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.12))',
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const GITHUB_ROW_STYLE = {
|
|
735
|
+
display: 'flex',
|
|
736
|
+
alignItems: 'center',
|
|
737
|
+
gap: '8px',
|
|
738
|
+
width: '100%',
|
|
739
|
+
padding: '8px 4px',
|
|
740
|
+
border: 'none',
|
|
741
|
+
background: 'transparent',
|
|
742
|
+
color: 'inherit',
|
|
743
|
+
font: 'inherit',
|
|
744
|
+
textAlign: 'left',
|
|
745
|
+
cursor: 'pointer',
|
|
746
|
+
borderBottom: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.08))',
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const GITHUB_INPUT_STYLE = {
|
|
750
|
+
width: '100%',
|
|
751
|
+
boxSizing: 'border-box',
|
|
752
|
+
padding: '6px 8px',
|
|
753
|
+
font: 'inherit',
|
|
754
|
+
color: 'inherit',
|
|
755
|
+
background: 'transparent',
|
|
756
|
+
border: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.2))',
|
|
757
|
+
borderRadius: 'var(--dsw-radius-sm, 6px)',
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* The add-a-workspace-from-GitHub dialog.
|
|
762
|
+
*
|
|
763
|
+
* Sign-in is the GitHub CLI's own session: the Host asks `gh` for the token it
|
|
764
|
+
* already holds, so this dialog never collects a credential. A clone becomes a
|
|
765
|
+
* workspace through the Workspace Remote, which is the same call the "+" menu
|
|
766
|
+
* makes after its folder picker returns a path.
|
|
767
|
+
*
|
|
768
|
+
* @param props - our lazy `configForms` and the `createWorkspace` opener.
|
|
769
|
+
* @returns the dialog, or null while it is closed or switched off.
|
|
770
|
+
*/
|
|
771
|
+
function GitHubPanel(props) {
|
|
772
|
+
const open = React.useSyncExternalStore(githubStore.subscribe, githubStore.isOpen)
|
|
773
|
+
const enabled = useSwitch(props.configForms, 'githubWorkspace', true)
|
|
774
|
+
const [phase, setPhase] = React.useState('idle')
|
|
775
|
+
const [account, setAccount] = React.useState(undefined)
|
|
776
|
+
const [repos, setRepos] = React.useState([])
|
|
777
|
+
const [query, setQuery] = React.useState('')
|
|
778
|
+
const [error, setError] = React.useState('')
|
|
779
|
+
const [busy, setBusy] = React.useState('')
|
|
780
|
+
|
|
781
|
+
React.useEffect(() => {
|
|
782
|
+
if (!open || !enabled) return undefined
|
|
783
|
+
let live = true
|
|
784
|
+
setPhase('loading')
|
|
785
|
+
setError('')
|
|
786
|
+
setBusy('')
|
|
787
|
+
setQuery('')
|
|
788
|
+
Promise.resolve()
|
|
789
|
+
.then(() => githubCall('status'))
|
|
790
|
+
.then(status => {
|
|
791
|
+
if (!live) return undefined
|
|
792
|
+
setAccount(status)
|
|
793
|
+
if (status.signedIn !== true) return undefined
|
|
794
|
+
return githubCall('repos').then(list => {
|
|
795
|
+
if (!live) return
|
|
796
|
+
setRepos(Array.isArray(list.repos) ? list.repos : [])
|
|
797
|
+
})
|
|
798
|
+
})
|
|
799
|
+
.then(() => { if (live) setPhase('ready') })
|
|
800
|
+
.catch(reason => {
|
|
801
|
+
if (!live) return
|
|
802
|
+
setError(reason instanceof Error ? reason.message : String(reason))
|
|
803
|
+
setPhase('ready')
|
|
804
|
+
})
|
|
805
|
+
return () => { live = false }
|
|
806
|
+
}, [open, enabled])
|
|
807
|
+
|
|
808
|
+
React.useEffect(() => {
|
|
809
|
+
if (!open) return undefined
|
|
810
|
+
const onKey = event => { if (event.key === 'Escape') githubStore.set(false) }
|
|
811
|
+
document.addEventListener('keydown', onKey)
|
|
812
|
+
return () => document.removeEventListener('keydown', onKey)
|
|
813
|
+
}, [open])
|
|
814
|
+
|
|
815
|
+
if (!open || !enabled) return null
|
|
816
|
+
|
|
817
|
+
const signedIn = account !== undefined && account.signedIn === true
|
|
818
|
+
const needle = query.trim().toLowerCase()
|
|
819
|
+
const visible = needle === ''
|
|
820
|
+
? repos
|
|
821
|
+
: repos.filter(repo => `${repo.fullName} ${repo.description}`.toLowerCase().includes(needle))
|
|
822
|
+
const root = account !== undefined && typeof account.root === 'string' ? account.root : ''
|
|
823
|
+
|
|
824
|
+
const clone = repo => {
|
|
825
|
+
if (busy !== '') return
|
|
826
|
+
setBusy(repo.fullName)
|
|
827
|
+
setError('')
|
|
828
|
+
Promise.resolve()
|
|
829
|
+
.then(() => githubCall('clone', { owner: repo.owner, name: repo.name }))
|
|
830
|
+
// The add-workspace flow owns creation when it opened this dialog; a
|
|
831
|
+
// dialog opened from the footer creates the workspace itself.
|
|
832
|
+
.then(result => {
|
|
833
|
+
const onPicked = githubStore.takePicker()
|
|
834
|
+
const land = onPicked === undefined
|
|
835
|
+
? Promise.resolve(props.createWorkspace(result.path))
|
|
836
|
+
: Promise.resolve(onPicked(result.path))
|
|
837
|
+
return land.then(() => result)
|
|
838
|
+
})
|
|
839
|
+
.then(result => {
|
|
840
|
+
setBusy('')
|
|
841
|
+
githubStore.set(false)
|
|
842
|
+
if (typeof console !== 'undefined' && typeof console.info === 'function') {
|
|
843
|
+
console.info('[aryacode-polish] workspace ready:', result.path)
|
|
844
|
+
}
|
|
845
|
+
})
|
|
846
|
+
.catch(reason => {
|
|
847
|
+
setBusy('')
|
|
848
|
+
setError(reason instanceof Error ? reason.message : String(reason))
|
|
849
|
+
})
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const rows = visible.slice(0, 200).map(repo => React.createElement('button', {
|
|
853
|
+
key: repo.fullName,
|
|
854
|
+
type: 'button',
|
|
855
|
+
style: GITHUB_ROW_STYLE,
|
|
856
|
+
disabled: busy !== '',
|
|
857
|
+
onClick: () => clone(repo),
|
|
858
|
+
title: repo.description === '' ? repo.fullName : `${repo.fullName} — ${repo.description}`,
|
|
859
|
+
'data-polish-github-row': repo.fullName,
|
|
860
|
+
},
|
|
861
|
+
React.createElement('span', { style: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
|
|
862
|
+
repo.fullName,
|
|
863
|
+
repo.isPrivate ? React.createElement('span', { style: { marginLeft: '6px', opacity: 0.6 } }, 'private') : null,
|
|
864
|
+
repo.isFork ? React.createElement('span', { style: { marginLeft: '6px', opacity: 0.6 } }, 'fork') : null),
|
|
865
|
+
repo.cloned === true
|
|
866
|
+
? React.createElement('span', { style: { opacity: 0.6 } }, 'cloned')
|
|
867
|
+
: null,
|
|
868
|
+
busy === repo.fullName ? React.createElement('span', { style: { opacity: 0.7 } }, 'cloning…') : null))
|
|
869
|
+
|
|
870
|
+
return ReactDOM.createPortal(
|
|
871
|
+
React.createElement('div', {
|
|
872
|
+
style: GITHUB_BACKDROP_STYLE,
|
|
873
|
+
'data-polish-github': 'dialog',
|
|
874
|
+
onPointerDown: event => { if (event.target === event.currentTarget) githubStore.set(false) },
|
|
875
|
+
},
|
|
876
|
+
React.createElement('div', { style: GITHUB_CARD_STYLE, role: 'dialog', 'aria-label': 'Add a workspace from GitHub' },
|
|
877
|
+
React.createElement('div', { style: { ...PANEL_TITLE_STYLE, marginBottom: '4px' } },
|
|
878
|
+
React.createElement('span', null, 'Add a workspace from GitHub'),
|
|
879
|
+
React.createElement('button', {
|
|
880
|
+
type: 'button',
|
|
881
|
+
onClick: () => githubStore.set(false),
|
|
882
|
+
style: { border: 'none', background: 'transparent', color: 'inherit', font: 'inherit', cursor: 'pointer', opacity: 0.7 },
|
|
883
|
+
'aria-label': 'Close',
|
|
884
|
+
}, '×')),
|
|
885
|
+
React.createElement('div', { style: { opacity: 0.75, minHeight: '18px' } },
|
|
886
|
+
phase === 'loading' && !signedIn ? 'Checking the GitHub CLI session…' : null,
|
|
887
|
+
signedIn ? `Signed in as ${String(account.login)} · cloning into ${root}` : null,
|
|
888
|
+
!signedIn && phase !== 'loading'
|
|
889
|
+
? 'The GitHub CLI is not signed in. Run `gh auth login` in a terminal, then reopen this dialog.'
|
|
890
|
+
: null),
|
|
891
|
+
signedIn
|
|
892
|
+
? React.createElement('input', {
|
|
893
|
+
style: { ...GITHUB_INPUT_STYLE, marginTop: '10px' },
|
|
894
|
+
value: query,
|
|
895
|
+
placeholder: 'Search your repositories',
|
|
896
|
+
'aria-label': 'Search your repositories',
|
|
897
|
+
autoFocus: true,
|
|
898
|
+
onChange: event => setQuery(event.target.value),
|
|
899
|
+
})
|
|
900
|
+
: null,
|
|
901
|
+
error !== ''
|
|
902
|
+
? React.createElement('div', { style: { marginTop: '10px', color: 'var(--dsw-alias-label-error, #b42318)' } }, error)
|
|
903
|
+
: null,
|
|
904
|
+
signedIn
|
|
905
|
+
? React.createElement('div', { style: GITHUB_LIST_STYLE, 'data-polish-github-list': String(visible.length) },
|
|
906
|
+
phase === 'loading' && visible.length === 0 ? React.createElement('div', { style: { padding: '10px 4px', opacity: 0.7 } }, 'Loading repositories…') : null,
|
|
907
|
+
phase === 'ready' && visible.length === 0 ? React.createElement('div', { style: { padding: '10px 4px', opacity: 0.7 } }, 'No repository matches that search.') : null,
|
|
908
|
+
...rows)
|
|
909
|
+
: null,
|
|
910
|
+
React.createElement('p', { style: { margin: '10px 0 0', color: 'var(--dsw-alias-label-tertiary, inherit)' } },
|
|
911
|
+
'The clone is added as a workspace. Sign-in comes from the GitHub CLI, so this plugin stores no token.')))
|
|
912
|
+
, document.body)
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/** The compact footer-row skin shared by the status line. */
|
|
916
|
+
const FOOT_ROW_STYLE = {
|
|
917
|
+
display: 'flex',
|
|
918
|
+
alignItems: 'center',
|
|
919
|
+
gap: '6px',
|
|
920
|
+
maxWidth: '100%',
|
|
921
|
+
minWidth: 0,
|
|
922
|
+
padding: '2px 6px',
|
|
923
|
+
border: 'none',
|
|
924
|
+
borderRadius: 'var(--dsw-radius-sm, 6px)',
|
|
925
|
+
background: 'transparent',
|
|
926
|
+
color: 'var(--dsw-alias-label-tertiary, inherit)',
|
|
927
|
+
font: 'inherit',
|
|
928
|
+
fontSize: '12px',
|
|
929
|
+
lineHeight: '18px',
|
|
930
|
+
fontVariantNumeric: 'tabular-nums',
|
|
931
|
+
whiteSpace: 'nowrap',
|
|
932
|
+
overflow: 'hidden',
|
|
933
|
+
textOverflow: 'ellipsis',
|
|
934
|
+
cursor: 'pointer',
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* The footer status line: which GitHub session the "+" menu will clone with.
|
|
939
|
+
*
|
|
940
|
+
* It reads the same `status` action the dialog does, so it costs one request
|
|
941
|
+
* when the Shell loads and one whenever the window regains focus.
|
|
942
|
+
*
|
|
943
|
+
* @param props - our lazy `configForms`.
|
|
944
|
+
* @returns the row, or null when the feature is switched off.
|
|
945
|
+
*/
|
|
946
|
+
function GithubStatus(props) {
|
|
947
|
+
const enabled = useSwitch(props.configForms, 'githubWorkspace', true)
|
|
948
|
+
const [status, setStatus] = React.useState(undefined)
|
|
949
|
+
React.useEffect(() => {
|
|
950
|
+
if (!enabled) return undefined
|
|
951
|
+
let live = true
|
|
952
|
+
const read = () => {
|
|
953
|
+
Promise.resolve()
|
|
954
|
+
.then(() => githubCall('status'))
|
|
955
|
+
.then(next => { if (live) setStatus(next) })
|
|
956
|
+
.catch(() => { if (live) setStatus({ signedIn: false }) })
|
|
957
|
+
}
|
|
958
|
+
read()
|
|
959
|
+
const onFocus = () => { read() }
|
|
960
|
+
window.addEventListener('focus', onFocus)
|
|
961
|
+
return () => {
|
|
962
|
+
live = false
|
|
963
|
+
window.removeEventListener('focus', onFocus)
|
|
964
|
+
}
|
|
965
|
+
}, [enabled])
|
|
966
|
+
if (!enabled) return null
|
|
967
|
+
const signedIn = status !== undefined && status.signedIn === true
|
|
968
|
+
const label = status === undefined
|
|
969
|
+
? 'GitHub · checking…'
|
|
970
|
+
: signedIn ? `GitHub · ${String(status.login)}` : 'GitHub · signed out'
|
|
971
|
+
return React.createElement('div', { style: { padding: '2px 8px 0', minWidth: 0 } },
|
|
972
|
+
React.createElement('button', {
|
|
973
|
+
type: 'button',
|
|
974
|
+
'data-polish-github': 'status',
|
|
975
|
+
'data-polish-github-state': signedIn ? 'in' : status === undefined ? 'checking' : 'out',
|
|
976
|
+
title: signedIn
|
|
977
|
+
? 'Add a workspace from GitHub'
|
|
978
|
+
: 'Sign in with the GitHub CLI (gh auth login), then add a repository as a workspace',
|
|
979
|
+
'aria-label': label,
|
|
980
|
+
onClick: () => githubStore.open(),
|
|
981
|
+
style: FOOT_ROW_STYLE,
|
|
982
|
+
onMouseEnter: event => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
|
|
983
|
+
onMouseLeave: event => { event.currentTarget.style.background = 'transparent' },
|
|
984
|
+
}, label))
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/** One row of the add-workspace source menu. */
|
|
988
|
+
const SOURCE_ROW_STYLE = {
|
|
989
|
+
display: 'flex',
|
|
990
|
+
flexDirection: 'column',
|
|
991
|
+
alignItems: 'flex-start',
|
|
992
|
+
gap: '2px',
|
|
993
|
+
width: '100%',
|
|
994
|
+
padding: '8px 10px',
|
|
995
|
+
border: 'none',
|
|
996
|
+
borderRadius: 'var(--dsw-radius-sm, 6px)',
|
|
997
|
+
background: 'transparent',
|
|
998
|
+
color: 'inherit',
|
|
999
|
+
font: 'inherit',
|
|
1000
|
+
textAlign: 'left',
|
|
1001
|
+
cursor: 'pointer',
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* The "+" add-workspace flow: choose where the workspace comes from.
|
|
1006
|
+
*
|
|
1007
|
+
* The Harness shows its own `Add workspace…` row only while this slot is
|
|
1008
|
+
* occupied, and a single-owner slot means this dialog stands in for the folder
|
|
1009
|
+
* picker. It therefore offers both sources: the machine (the Host's own OS
|
|
1010
|
+
* chooser when the composition serves the `native` capability, otherwise a
|
|
1011
|
+
* folder list driven by the browse primitives) and GitHub.
|
|
1012
|
+
*
|
|
1013
|
+
* @param props - the slot owner's `open`/`busy`/`onPicked`/`onCancel`/`onError`,
|
|
1014
|
+
* our lazy `configForms`, and the directory-picker calls.
|
|
1015
|
+
* @returns the flow, or null while it is closed or switched off.
|
|
1016
|
+
*/
|
|
1017
|
+
function WorkspaceSourceFlow(props) {
|
|
1018
|
+
const enabled = useSwitch(props.configForms, 'workspaceSources', true)
|
|
1019
|
+
const [step, setStep] = React.useState('menu')
|
|
1020
|
+
const [listing, setListing] = React.useState(undefined)
|
|
1021
|
+
const [error, setError] = React.useState('')
|
|
1022
|
+
const [busy, setBusy] = React.useState(false)
|
|
1023
|
+
|
|
1024
|
+
const cancel = () => {
|
|
1025
|
+
if (typeof props.onCancel === 'function') props.onCancel()
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// Escape closes the flow. Declared with the other hooks, before the early
|
|
1029
|
+
// return, so the hook order stays fixed while the flow opens and closes.
|
|
1030
|
+
React.useEffect(() => {
|
|
1031
|
+
if (!enabled || props.open !== true) return undefined
|
|
1032
|
+
const onKey = event => { if (event.key === 'Escape') cancel() }
|
|
1033
|
+
document.addEventListener('keydown', onKey)
|
|
1034
|
+
return () => document.removeEventListener('keydown', onKey)
|
|
1035
|
+
}, [enabled, props.open])
|
|
1036
|
+
|
|
1037
|
+
if (!enabled || props.open !== true) return null
|
|
1038
|
+
|
|
1039
|
+
const fail = message => {
|
|
1040
|
+
setBusy(false)
|
|
1041
|
+
setError(message)
|
|
1042
|
+
}
|
|
1043
|
+
const messageOf = reason => (reason instanceof Error ? reason.message : String(reason))
|
|
1044
|
+
|
|
1045
|
+
const land = path => {
|
|
1046
|
+
setBusy(true)
|
|
1047
|
+
setError('')
|
|
1048
|
+
Promise.resolve()
|
|
1049
|
+
.then(() => props.onPicked(path))
|
|
1050
|
+
.catch(reason => fail(messageOf(reason)))
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
const browse = path => {
|
|
1054
|
+
setBusy(true)
|
|
1055
|
+
setError('')
|
|
1056
|
+
Promise.resolve()
|
|
1057
|
+
.then(() => foldersCall({ action: 'ls', path }))
|
|
1058
|
+
.then(level => {
|
|
1059
|
+
setListing(level)
|
|
1060
|
+
setBusy(false)
|
|
1061
|
+
setStep('browse')
|
|
1062
|
+
})
|
|
1063
|
+
.catch(reason => fail(messageOf(reason)))
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* Start the folder browser at the machine's roots.
|
|
1068
|
+
*
|
|
1069
|
+
* This deliberately does not call the Host's directory picker: a composed
|
|
1070
|
+
* picker serves either `browse` or `native`, never both, and the browser build
|
|
1071
|
+
* composes the one whose chooser cannot open there — a `pick` call would wait
|
|
1072
|
+
* for a dialog that never appears. Our own listing behaves the same in the app
|
|
1073
|
+
* and in the browser, and hands the chosen path back through `onPicked`.
|
|
1074
|
+
*/
|
|
1075
|
+
const chooseOnPc = () => {
|
|
1076
|
+
if (busy) return
|
|
1077
|
+
setBusy(true)
|
|
1078
|
+
setError('')
|
|
1079
|
+
Promise.resolve()
|
|
1080
|
+
.then(() => foldersCall({ action: 'roots' }))
|
|
1081
|
+
.then(payload => {
|
|
1082
|
+
const roots = Array.isArray(payload.roots) ? payload.roots : []
|
|
1083
|
+
setListing({
|
|
1084
|
+
path: '',
|
|
1085
|
+
parent: null,
|
|
1086
|
+
entries: roots.map(root => ({ name: root.label, path: root.path })),
|
|
1087
|
+
})
|
|
1088
|
+
setBusy(false)
|
|
1089
|
+
setStep('browse')
|
|
1090
|
+
})
|
|
1091
|
+
.catch(reason => fail(messageOf(reason)))
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
const openGithub = () => {
|
|
1095
|
+
githubStore.open(path => props.onPicked(path))
|
|
1096
|
+
cancel()
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
const rows = step === 'menu'
|
|
1100
|
+
? [
|
|
1101
|
+
React.createElement('button', {
|
|
1102
|
+
key: 'pc',
|
|
1103
|
+
type: 'button',
|
|
1104
|
+
'data-polish-source': 'machine',
|
|
1105
|
+
disabled: busy,
|
|
1106
|
+
onClick: chooseOnPc,
|
|
1107
|
+
style: SOURCE_ROW_STYLE,
|
|
1108
|
+
onMouseEnter: event => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
|
|
1109
|
+
onMouseLeave: event => { event.currentTarget.style.background = 'transparent' },
|
|
1110
|
+
},
|
|
1111
|
+
React.createElement('span', { style: { fontWeight: 500 } }, 'On this PC'),
|
|
1112
|
+
React.createElement('span', { style: { opacity: 0.7 } }, 'Choose a folder on this machine')),
|
|
1113
|
+
React.createElement('button', {
|
|
1114
|
+
key: 'github',
|
|
1115
|
+
type: 'button',
|
|
1116
|
+
'data-polish-source': 'github',
|
|
1117
|
+
disabled: busy,
|
|
1118
|
+
onClick: openGithub,
|
|
1119
|
+
style: SOURCE_ROW_STYLE,
|
|
1120
|
+
onMouseEnter: event => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
|
|
1121
|
+
onMouseLeave: event => { event.currentTarget.style.background = 'transparent' },
|
|
1122
|
+
},
|
|
1123
|
+
React.createElement('span', { style: { fontWeight: 500 } }, 'GitHub repository…'),
|
|
1124
|
+
React.createElement('span', { style: { opacity: 0.7 } }, 'Clone one of your repositories as a workspace')),
|
|
1125
|
+
]
|
|
1126
|
+
: [
|
|
1127
|
+
React.createElement('div', {
|
|
1128
|
+
key: 'head',
|
|
1129
|
+
style: { display: 'flex', alignItems: 'center', gap: '8px', padding: '2px 4px 6px', minWidth: 0 },
|
|
1130
|
+
},
|
|
1131
|
+
React.createElement('span', {
|
|
1132
|
+
style: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
|
|
1133
|
+
title: listing !== undefined ? listing.path : '',
|
|
1134
|
+
}, listing === undefined || listing.path === '' ? 'This PC' : listing.path),
|
|
1135
|
+
listing === undefined || listing.path === ''
|
|
1136
|
+
? null
|
|
1137
|
+
: React.createElement('button', {
|
|
1138
|
+
type: 'button',
|
|
1139
|
+
'data-polish-pick': 'here',
|
|
1140
|
+
disabled: busy,
|
|
1141
|
+
onClick: () => { if (listing !== undefined) land(listing.path) },
|
|
1142
|
+
style: { ...FOOT_ROW_STYLE, color: 'var(--dsw-alias-label-primary, inherit)', background: 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
|
|
1143
|
+
}, 'Use this folder')),
|
|
1144
|
+
...(listing === undefined || listing.parent === null || listing.parent === undefined
|
|
1145
|
+
? []
|
|
1146
|
+
: [React.createElement('button', {
|
|
1147
|
+
key: 'up',
|
|
1148
|
+
type: 'button',
|
|
1149
|
+
'data-polish-folder': '..',
|
|
1150
|
+
disabled: busy,
|
|
1151
|
+
onClick: () => browse(listing.parent),
|
|
1152
|
+
style: SOURCE_ROW_STYLE,
|
|
1153
|
+
onMouseEnter: event => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
|
|
1154
|
+
onMouseLeave: event => { event.currentTarget.style.background = 'transparent' },
|
|
1155
|
+
}, React.createElement('span', { title: listing.parent }, '..'))]),
|
|
1156
|
+
...(listing === undefined
|
|
1157
|
+
? []
|
|
1158
|
+
: listing.entries.map(entry => React.createElement('button', {
|
|
1159
|
+
key: entry.path,
|
|
1160
|
+
type: 'button',
|
|
1161
|
+
'data-polish-folder': entry.name,
|
|
1162
|
+
disabled: busy,
|
|
1163
|
+
onClick: () => browse(entry.path),
|
|
1164
|
+
style: SOURCE_ROW_STYLE,
|
|
1165
|
+
onMouseEnter: event => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
|
|
1166
|
+
onMouseLeave: event => { event.currentTarget.style.background = 'transparent' },
|
|
1167
|
+
}, React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title: entry.path }, entry.name)))),
|
|
1168
|
+
]
|
|
1169
|
+
|
|
1170
|
+
// The flow is rendered inside the Shell's add-workspace popover, which is
|
|
1171
|
+
// narrow and sits under the sidebar's own stacking order. A portal to the body
|
|
1172
|
+
// puts it above everything and keeps the rows readable at their own width.
|
|
1173
|
+
return ReactDOM.createPortal(
|
|
1174
|
+
React.createElement('div', {
|
|
1175
|
+
style: GITHUB_BACKDROP_STYLE,
|
|
1176
|
+
'data-polish-flow-backdrop': step,
|
|
1177
|
+
onPointerDown: event => { if (event.target === event.currentTarget) cancel() },
|
|
1178
|
+
},
|
|
1179
|
+
React.createElement('div', {
|
|
1180
|
+
'data-polish-flow': step,
|
|
1181
|
+
role: 'dialog',
|
|
1182
|
+
'aria-label': 'Add a workspace',
|
|
1183
|
+
style: {
|
|
1184
|
+
display: 'flex',
|
|
1185
|
+
flexDirection: 'column',
|
|
1186
|
+
gap: '2px',
|
|
1187
|
+
width: 'min(360px, calc(100vw - 32px))',
|
|
1188
|
+
maxHeight: 'min(420px, calc(100vh - 96px))',
|
|
1189
|
+
padding: '6px',
|
|
1190
|
+
overflowY: 'auto',
|
|
1191
|
+
borderRadius: 'var(--dsw-radius-lg, 12px)',
|
|
1192
|
+
background: 'var(--dsw-specific-menu, Canvas)',
|
|
1193
|
+
boxShadow: 'var(--dsw-elevation-prominent, 0 8px 24px rgba(0,0,0,0.18))',
|
|
1194
|
+
fontSize: '12px',
|
|
1195
|
+
lineHeight: '18px',
|
|
1196
|
+
color: 'var(--dsw-alias-label-secondary, inherit)',
|
|
1197
|
+
},
|
|
1198
|
+
},
|
|
1199
|
+
error !== ''
|
|
1200
|
+
? React.createElement('div', { style: { padding: '4px 6px', color: 'var(--dsw-alias-label-error, #b42318)' } }, error)
|
|
1201
|
+
: null,
|
|
1202
|
+
...rows,
|
|
1203
|
+
step === 'menu'
|
|
1204
|
+
? null
|
|
1205
|
+
: React.createElement('button', {
|
|
1206
|
+
type: 'button',
|
|
1207
|
+
'data-polish-pick': 'back',
|
|
1208
|
+
onClick: () => { setStep('menu'); setListing(undefined); setError('') },
|
|
1209
|
+
style: { ...FOOT_ROW_STYLE, justifyContent: 'flex-start' },
|
|
1210
|
+
}, 'Back'),
|
|
1211
|
+
React.createElement('button', {
|
|
1212
|
+
type: 'button',
|
|
1213
|
+
'data-polish-pick': 'cancel',
|
|
1214
|
+
onClick: cancel,
|
|
1215
|
+
style: { ...FOOT_ROW_STYLE, justifyContent: 'flex-start' },
|
|
1216
|
+
}, 'Cancel'))),
|
|
1217
|
+
document.body)
|
|
1218
|
+
}
|
|
1219
|
+
|
|
619
1220
|
return {
|
|
620
1221
|
inject: ['slots'],
|
|
621
1222
|
/**
|
|
@@ -678,5 +1279,45 @@ return {
|
|
|
678
1279
|
inject: () => ({ configForms, openFolder, workspaces: workspaceCtx.get('workspaces') }),
|
|
679
1280
|
}, OpenFolderMenuItem))
|
|
680
1281
|
})
|
|
1282
|
+
// Adding a workspace from GitHub goes through the Workspace service's own
|
|
1283
|
+
// create call — the same one the "+" menu makes once its folder picker
|
|
1284
|
+
// returns a path — so a clone lands in the sidebar exactly like a picked
|
|
1285
|
+
// folder does, with no Harness change anywhere. The service takes the plain
|
|
1286
|
+
// path and throws on refusal; the Remote namespace underneath it is
|
|
1287
|
+
// `ctx.remote.workspace` (singular), which is why this injects the service.
|
|
1288
|
+
ctx.inject(['workspaces'], (githubCtx) => {
|
|
1289
|
+
const createWorkspace = async (path) => {
|
|
1290
|
+
const workspace = await githubCtx.workspaces.create({ path })
|
|
1291
|
+
return workspace
|
|
1292
|
+
}
|
|
1293
|
+
githubCtx.slots.inject('shell.overlay', () => githubCtx.slots.register({
|
|
1294
|
+
name: 'shell.overlay',
|
|
1295
|
+
id: 'polish-github',
|
|
1296
|
+
inject: () => ({ configForms, createWorkspace }),
|
|
1297
|
+
}, GitHubPanel))
|
|
1298
|
+
// The footer keeps the sign-in state, not an add button: adding a
|
|
1299
|
+
// workspace belongs in the "+" menu, where the Shell says it does.
|
|
1300
|
+
githubCtx.slots.inject('sidebar.footer.action', () => githubCtx.slots.register({
|
|
1301
|
+
name: 'sidebar.footer.action',
|
|
1302
|
+
id: 'polish-github-status',
|
|
1303
|
+
order: 4,
|
|
1304
|
+
inject: () => ({ configForms }),
|
|
1305
|
+
}, GithubStatus))
|
|
1306
|
+
})
|
|
1307
|
+
// The "+" menu's `Add workspace…` row exists only while a plugin occupies
|
|
1308
|
+
// this single-owner hole, so occupying it means standing in for the folder
|
|
1309
|
+
// picker: the flow offers the machine and GitHub as sources. It needs no
|
|
1310
|
+
// Remote injection: the folder list and the clone both come from this
|
|
1311
|
+
// bundle's own Host routes, which is what makes the flow behave the same in
|
|
1312
|
+
// the browser and in the app.
|
|
1313
|
+
ctx.slots.inject('sidebar.workspaces.directoryFlow', () => ctx.slots.register({
|
|
1314
|
+
name: 'sidebar.workspaces.directoryFlow',
|
|
1315
|
+
// A single slot clashes only at the exact priority; another priority
|
|
1316
|
+
// shadows it and the LOWEST priority renders. The shipped pickers
|
|
1317
|
+
// register at 0, so -1 is what puts this flow in front of them without
|
|
1318
|
+
// making their own activation throw.
|
|
1319
|
+
priority: -1,
|
|
1320
|
+
inject: () => ({ configForms }),
|
|
1321
|
+
}, WorkspaceSourceFlow))
|
|
681
1322
|
},
|
|
682
1323
|
}
|