@aryacode/dsh-aryacode-polish 0.4.4 → 0.5.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 +30 -2
- package/README.md +3 -1
- package/client/face.js +329 -5
- package/client/switches.json +7 -1
- package/docs/github-workspaces.md +63 -0
- package/docs/workspace-folder.md +6 -0
- package/entries/github.js +347 -0
- package/entries/registry.js +8 -0
- package/lib/client.js +336 -6
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.5.0] - 2026-10-27
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Add a workspace from GitHub.** A new switch (`githubWorkspace`) adds an
|
|
8
|
+
**Add workspace from GitHub** entry to the sidebar foot. It lists the
|
|
9
|
+
repositories the GitHub CLI session can clone, clones the chosen one into
|
|
10
|
+
`<home>\AryaCode\<owner>\<repo>`, and registers that folder as a workspace
|
|
11
|
+
through the same controller call the "+" menu uses. The token never reaches the
|
|
12
|
+
browser: the Host reads it with `gh auth token` and hands it to git as an
|
|
13
|
+
`http.extraHeader` in git's environment, so it stays out of the URL, the
|
|
14
|
+
command line, and any stored remote. See
|
|
15
|
+
[`docs/github-workspaces.md`](docs/github-workspaces.md).
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- **A clone would have failed on a real machine.** `cloneRepo` spawned the
|
|
20
|
+
argument list without the `git` command name and passed the token as an option
|
|
21
|
+
the runner never read, so the clone carried no authentication at all. It now
|
|
22
|
+
spawns `git` with the token in `GIT_CONFIG_*`, which is what GitHub's git
|
|
23
|
+
server accepts, and `test/github.spec.mjs` pins the spawn shape.
|
|
24
|
+
- **The signed-out reason told you nothing you could act on.** It now names the
|
|
25
|
+
command that fixes it (`gh auth login`).
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- `pnpm test` also runs `test/github.spec.mjs`; the suite is 87 tests.
|
|
30
|
+
|
|
3
31
|
## [0.4.4] - 2026-10-27
|
|
4
32
|
|
|
5
33
|
### Fixed
|
|
@@ -232,8 +260,8 @@
|
|
|
232
260
|
|
|
233
261
|
### Fixed
|
|
234
262
|
|
|
235
|
-
- The pill's `≈` was stored as cp1252 mojibake (
|
|
236
|
-
|
|
263
|
+
- The pill's `≈` was stored as cp1252 mojibake (`≈`), so a real turn rendered
|
|
264
|
+
`≈$0.003` instead of `≈$0.003`. Same cause as the two strings repaired in
|
|
237
265
|
0.2.1 — a PowerShell round-trip over the face source — and the last of that
|
|
238
266
|
damage. Verified against a real turn in Dev AryaCode.
|
|
239
267
|
|
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ installed into and patches nothing. It is one package, one Plugins row, and one
|
|
|
7
7
|
switch per feature, so a refinement that turns out to be wrong for a machine can
|
|
8
8
|
be turned off without uninstalling anything.
|
|
9
9
|
|
|
10
|
-
Status: **
|
|
10
|
+
Status: **four features shipped.** The version is the package's, and the card
|
|
11
11
|
header already shows it.
|
|
12
12
|
|
|
13
13
|
## What it is
|
|
@@ -33,6 +33,7 @@ Configure page automatically.
|
|
|
33
33
|
| **Estimated session cost** (`sessionCost`) | On | A `≈$0.39` pill in the composer dock that opens a breakdown card — cache hit, uncached input, cached input, output, requests, and the estimate — priced from the session's recorded usage at the published DeepSeek rates. |
|
|
34
34
|
| **Account balance** (`accountBalance`) | On | The account's remaining wallet (`$17.33`) in the sidebar foot, above the account row. Renders nothing when the Host exposes no wallet, so it never shows a number nobody gave it. |
|
|
35
35
|
| **Open workspace folder** (`openWorkspaceFolder`) | On | An **Open workspace folder** row in a Session's "…" menu that opens the Session's workspace in Explorer/Finder. Renders nothing when the Session has no workspace or the Host cannot open a path. See [Open a workspace folder](docs/workspace-folder.md) for why the request had to land here instead of on the workspace row itself. |
|
|
36
|
+
| **Add a workspace from GitHub** (`githubWorkspace`) | On | **Add workspace from GitHub** in the sidebar foot: the dialog lists the repositories your GitHub CLI session can clone, and cloning one creates the workspace. The token never enters the browser — it is read on the Host, used only as git's http auth header, and never stored. See [GitHub workspaces](docs/github-workspaces.md). |
|
|
36
37
|
|
|
37
38
|
### How the cost is computed
|
|
38
39
|
|
|
@@ -122,6 +123,7 @@ entries/polish.js the row: Config derived from the registry, apply()
|
|
|
122
123
|
entries/registry.js the feature list — the one place a feature is declared
|
|
123
124
|
entries/usage.js the fold: model-attributed usage out of the session log
|
|
124
125
|
entries/cost.js the polishCost projection: rates, pricing, host mount
|
|
126
|
+
entries/github.js the GitHub route: gh token, repository list, clone, workspace
|
|
125
127
|
entries/config.js volatile-reference accessors (resolve/flag/list/text)
|
|
126
128
|
lib/index.js mount point re-exporting the row
|
|
127
129
|
client/face.js browser face source: the Configure page and every surface
|
package/client/face.js
CHANGED
|
@@ -530,22 +530,24 @@ function BalanceLine(props) {
|
|
|
530
530
|
return React.createElement('div', { 'data-polish-balance': 'empty', style: { display: 'none' } })
|
|
531
531
|
}
|
|
532
532
|
const money = formatBalance(wallet)
|
|
533
|
+
// The foot is a single row shared with the GitHub entry, so the amount stays
|
|
534
|
+
// bare: a tooltip names it, and nothing here competes for width.
|
|
533
535
|
return React.createElement('div', {
|
|
534
536
|
title: 'Remaining account balance',
|
|
537
|
+
'data-polish-balance': 'ready',
|
|
535
538
|
style: {
|
|
536
539
|
display: 'flex',
|
|
537
540
|
alignItems: 'center',
|
|
538
541
|
gap: '6px',
|
|
539
|
-
padding: '2px
|
|
542
|
+
padding: '2px 4px 4px 8px',
|
|
540
543
|
fontSize: '12px',
|
|
541
544
|
lineHeight: '16px',
|
|
542
|
-
|
|
543
|
-
overflow: 'hidden',
|
|
545
|
+
flex: '0 0 auto',
|
|
544
546
|
whiteSpace: 'nowrap',
|
|
545
|
-
textOverflow: 'ellipsis',
|
|
546
547
|
opacity: 0.75,
|
|
548
|
+
fontVariantNumeric: 'tabular-nums',
|
|
547
549
|
},
|
|
548
|
-
},
|
|
550
|
+
}, money)
|
|
549
551
|
}
|
|
550
552
|
|
|
551
553
|
/**
|
|
@@ -616,6 +618,305 @@ function OpenFolderMenuItem(props) {
|
|
|
616
618
|
return React.createElement('button', { type: 'button', role: 'menuitem', onClick: onSelect }, label)
|
|
617
619
|
}
|
|
618
620
|
|
|
621
|
+
/** The Host route the GitHub halves talk over. Document-relative, like every app route. */
|
|
622
|
+
const GITHUB_ROUTE = 'api/polish.github'
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Whether the GitHub panel is open. One store for the whole bundle: the footer
|
|
626
|
+
* row opens it, and the overlay panel is the only reader.
|
|
627
|
+
*/
|
|
628
|
+
const githubStore = (() => {
|
|
629
|
+
let open = false
|
|
630
|
+
const listeners = new Set()
|
|
631
|
+
return {
|
|
632
|
+
isOpen: () => open,
|
|
633
|
+
subscribe(listener) {
|
|
634
|
+
listeners.add(listener)
|
|
635
|
+
return () => { listeners.delete(listener) }
|
|
636
|
+
},
|
|
637
|
+
set(next) {
|
|
638
|
+
if (open === next) return
|
|
639
|
+
open = next
|
|
640
|
+
for (const listener of listeners) listener()
|
|
641
|
+
},
|
|
642
|
+
}
|
|
643
|
+
})()
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Post one action to the GitHub route.
|
|
647
|
+
* @param action - `status`, `repos` or `clone`.
|
|
648
|
+
* @param extra - fields the action needs.
|
|
649
|
+
* @returns the route's `value`, or throws with its own message.
|
|
650
|
+
*/
|
|
651
|
+
async function githubCall(action, extra) {
|
|
652
|
+
const response = await fetch(GITHUB_ROUTE, {
|
|
653
|
+
method: 'POST',
|
|
654
|
+
headers: { 'content-type': 'application/json' },
|
|
655
|
+
body: JSON.stringify({ action, ...(extra ?? {}) }),
|
|
656
|
+
})
|
|
657
|
+
const payload = await response.json().catch(() => undefined)
|
|
658
|
+
if (payload === undefined || typeof payload !== 'object') {
|
|
659
|
+
throw new Error(`the Host answered ${String(response.status)}`)
|
|
660
|
+
}
|
|
661
|
+
if (payload.ok !== true) throw new Error(payload.error || 'GitHub request failed')
|
|
662
|
+
return payload.value
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** Modal backdrop and panel skin, matching the Shell's own dialogs. */
|
|
666
|
+
const GITHUB_BACKDROP_STYLE = {
|
|
667
|
+
position: 'fixed',
|
|
668
|
+
inset: 0,
|
|
669
|
+
zIndex: 1200,
|
|
670
|
+
display: 'flex',
|
|
671
|
+
alignItems: 'center',
|
|
672
|
+
justifyContent: 'center',
|
|
673
|
+
background: 'rgba(16, 18, 22, 0.32)',
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const GITHUB_CARD_STYLE = {
|
|
677
|
+
...PANEL_STYLE,
|
|
678
|
+
position: 'static',
|
|
679
|
+
width: 'min(560px, calc(100vw - 32px))',
|
|
680
|
+
maxHeight: 'min(560px, calc(100vh - 64px))',
|
|
681
|
+
display: 'flex',
|
|
682
|
+
flexDirection: 'column',
|
|
683
|
+
overflow: 'hidden',
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const GITHUB_LIST_STYLE = {
|
|
687
|
+
marginTop: '10px',
|
|
688
|
+
overflowY: 'auto',
|
|
689
|
+
borderTop: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.12))',
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const GITHUB_ROW_STYLE = {
|
|
693
|
+
display: 'flex',
|
|
694
|
+
alignItems: 'center',
|
|
695
|
+
gap: '8px',
|
|
696
|
+
width: '100%',
|
|
697
|
+
padding: '8px 4px',
|
|
698
|
+
border: 'none',
|
|
699
|
+
background: 'transparent',
|
|
700
|
+
color: 'inherit',
|
|
701
|
+
font: 'inherit',
|
|
702
|
+
textAlign: 'left',
|
|
703
|
+
cursor: 'pointer',
|
|
704
|
+
borderBottom: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.08))',
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const GITHUB_INPUT_STYLE = {
|
|
708
|
+
width: '100%',
|
|
709
|
+
boxSizing: 'border-box',
|
|
710
|
+
padding: '6px 8px',
|
|
711
|
+
font: 'inherit',
|
|
712
|
+
color: 'inherit',
|
|
713
|
+
background: 'transparent',
|
|
714
|
+
border: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.2))',
|
|
715
|
+
borderRadius: 'var(--dsw-radius-sm, 6px)',
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* The add-a-workspace-from-GitHub dialog.
|
|
720
|
+
*
|
|
721
|
+
* Sign-in is the GitHub CLI's own session: the Host asks `gh` for the token it
|
|
722
|
+
* already holds, so this dialog never collects a credential. A clone becomes a
|
|
723
|
+
* workspace through the Workspace Remote, which is the same call the "+" menu
|
|
724
|
+
* makes after its folder picker returns a path.
|
|
725
|
+
*
|
|
726
|
+
* @param props - our lazy `configForms` and the `createWorkspace` opener.
|
|
727
|
+
* @returns the dialog, or null while it is closed or switched off.
|
|
728
|
+
*/
|
|
729
|
+
function GitHubPanel(props) {
|
|
730
|
+
const open = React.useSyncExternalStore(githubStore.subscribe, githubStore.isOpen)
|
|
731
|
+
const enabled = useSwitch(props.configForms, 'githubWorkspace', true)
|
|
732
|
+
const [phase, setPhase] = React.useState('idle')
|
|
733
|
+
const [account, setAccount] = React.useState(undefined)
|
|
734
|
+
const [repos, setRepos] = React.useState([])
|
|
735
|
+
const [query, setQuery] = React.useState('')
|
|
736
|
+
const [error, setError] = React.useState('')
|
|
737
|
+
const [busy, setBusy] = React.useState('')
|
|
738
|
+
|
|
739
|
+
React.useEffect(() => {
|
|
740
|
+
if (!open || !enabled) return undefined
|
|
741
|
+
let live = true
|
|
742
|
+
setPhase('loading')
|
|
743
|
+
setError('')
|
|
744
|
+
setBusy('')
|
|
745
|
+
setQuery('')
|
|
746
|
+
Promise.resolve()
|
|
747
|
+
.then(() => githubCall('status'))
|
|
748
|
+
.then(status => {
|
|
749
|
+
if (!live) return undefined
|
|
750
|
+
setAccount(status)
|
|
751
|
+
if (status.signedIn !== true) return undefined
|
|
752
|
+
return githubCall('repos').then(list => {
|
|
753
|
+
if (!live) return
|
|
754
|
+
setRepos(Array.isArray(list.repos) ? list.repos : [])
|
|
755
|
+
})
|
|
756
|
+
})
|
|
757
|
+
.then(() => { if (live) setPhase('ready') })
|
|
758
|
+
.catch(reason => {
|
|
759
|
+
if (!live) return
|
|
760
|
+
setError(reason instanceof Error ? reason.message : String(reason))
|
|
761
|
+
setPhase('ready')
|
|
762
|
+
})
|
|
763
|
+
return () => { live = false }
|
|
764
|
+
}, [open, enabled])
|
|
765
|
+
|
|
766
|
+
React.useEffect(() => {
|
|
767
|
+
if (!open) return undefined
|
|
768
|
+
const onKey = event => { if (event.key === 'Escape') githubStore.set(false) }
|
|
769
|
+
document.addEventListener('keydown', onKey)
|
|
770
|
+
return () => document.removeEventListener('keydown', onKey)
|
|
771
|
+
}, [open])
|
|
772
|
+
|
|
773
|
+
if (!open || !enabled) return null
|
|
774
|
+
|
|
775
|
+
const signedIn = account !== undefined && account.signedIn === true
|
|
776
|
+
const needle = query.trim().toLowerCase()
|
|
777
|
+
const visible = needle === ''
|
|
778
|
+
? repos
|
|
779
|
+
: repos.filter(repo => `${repo.fullName} ${repo.description}`.toLowerCase().includes(needle))
|
|
780
|
+
const root = account !== undefined && typeof account.root === 'string' ? account.root : ''
|
|
781
|
+
|
|
782
|
+
const clone = repo => {
|
|
783
|
+
if (busy !== '') return
|
|
784
|
+
setBusy(repo.fullName)
|
|
785
|
+
setError('')
|
|
786
|
+
Promise.resolve()
|
|
787
|
+
.then(() => githubCall('clone', { owner: repo.owner, name: repo.name }))
|
|
788
|
+
.then(result => Promise.resolve(props.createWorkspace(result.path)).then(() => result))
|
|
789
|
+
.then(result => {
|
|
790
|
+
setBusy('')
|
|
791
|
+
githubStore.set(false)
|
|
792
|
+
if (typeof console !== 'undefined' && typeof console.info === 'function') {
|
|
793
|
+
console.info('[aryacode-polish] workspace ready:', result.path)
|
|
794
|
+
}
|
|
795
|
+
})
|
|
796
|
+
.catch(reason => {
|
|
797
|
+
setBusy('')
|
|
798
|
+
setError(reason instanceof Error ? reason.message : String(reason))
|
|
799
|
+
})
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const rows = visible.slice(0, 200).map(repo => React.createElement('button', {
|
|
803
|
+
key: repo.fullName,
|
|
804
|
+
type: 'button',
|
|
805
|
+
style: GITHUB_ROW_STYLE,
|
|
806
|
+
disabled: busy !== '',
|
|
807
|
+
onClick: () => clone(repo),
|
|
808
|
+
title: repo.description === '' ? repo.fullName : `${repo.fullName} — ${repo.description}`,
|
|
809
|
+
'data-polish-github-row': repo.fullName,
|
|
810
|
+
},
|
|
811
|
+
React.createElement('span', { style: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
|
|
812
|
+
repo.fullName,
|
|
813
|
+
repo.isPrivate ? React.createElement('span', { style: { marginLeft: '6px', opacity: 0.6 } }, 'private') : null,
|
|
814
|
+
repo.isFork ? React.createElement('span', { style: { marginLeft: '6px', opacity: 0.6 } }, 'fork') : null),
|
|
815
|
+
repo.cloned === true
|
|
816
|
+
? React.createElement('span', { style: { opacity: 0.6 } }, 'cloned')
|
|
817
|
+
: null,
|
|
818
|
+
busy === repo.fullName ? React.createElement('span', { style: { opacity: 0.7 } }, 'cloning…') : null))
|
|
819
|
+
|
|
820
|
+
return ReactDOM.createPortal(
|
|
821
|
+
React.createElement('div', {
|
|
822
|
+
style: GITHUB_BACKDROP_STYLE,
|
|
823
|
+
'data-polish-github': 'dialog',
|
|
824
|
+
onPointerDown: event => { if (event.target === event.currentTarget) githubStore.set(false) },
|
|
825
|
+
},
|
|
826
|
+
React.createElement('div', { style: GITHUB_CARD_STYLE, role: 'dialog', 'aria-label': 'Add a workspace from GitHub' },
|
|
827
|
+
React.createElement('div', { style: { ...PANEL_TITLE_STYLE, marginBottom: '4px' } },
|
|
828
|
+
React.createElement('span', null, 'Add a workspace from GitHub'),
|
|
829
|
+
React.createElement('button', {
|
|
830
|
+
type: 'button',
|
|
831
|
+
onClick: () => githubStore.set(false),
|
|
832
|
+
style: { border: 'none', background: 'transparent', color: 'inherit', font: 'inherit', cursor: 'pointer', opacity: 0.7 },
|
|
833
|
+
'aria-label': 'Close',
|
|
834
|
+
}, '×')),
|
|
835
|
+
React.createElement('div', { style: { opacity: 0.75, minHeight: '18px' } },
|
|
836
|
+
phase === 'loading' && !signedIn ? 'Checking the GitHub CLI session…' : null,
|
|
837
|
+
signedIn ? `Signed in as ${String(account.login)} · cloning into ${root}` : null,
|
|
838
|
+
!signedIn && phase !== 'loading'
|
|
839
|
+
? 'The GitHub CLI is not signed in. Run `gh auth login` in a terminal, then reopen this dialog.'
|
|
840
|
+
: null),
|
|
841
|
+
signedIn
|
|
842
|
+
? React.createElement('input', {
|
|
843
|
+
style: { ...GITHUB_INPUT_STYLE, marginTop: '10px' },
|
|
844
|
+
value: query,
|
|
845
|
+
placeholder: 'Search your repositories',
|
|
846
|
+
'aria-label': 'Search your repositories',
|
|
847
|
+
autoFocus: true,
|
|
848
|
+
onChange: event => setQuery(event.target.value),
|
|
849
|
+
})
|
|
850
|
+
: null,
|
|
851
|
+
error !== ''
|
|
852
|
+
? React.createElement('div', { style: { marginTop: '10px', color: 'var(--dsw-alias-label-error, #b42318)' } }, error)
|
|
853
|
+
: null,
|
|
854
|
+
signedIn
|
|
855
|
+
? React.createElement('div', { style: GITHUB_LIST_STYLE, 'data-polish-github-list': String(visible.length) },
|
|
856
|
+
phase === 'loading' && visible.length === 0 ? React.createElement('div', { style: { padding: '10px 4px', opacity: 0.7 } }, 'Loading repositories…') : null,
|
|
857
|
+
phase === 'ready' && visible.length === 0 ? React.createElement('div', { style: { padding: '10px 4px', opacity: 0.7 } }, 'No repository matches that search.') : null,
|
|
858
|
+
...rows)
|
|
859
|
+
: null,
|
|
860
|
+
React.createElement('p', { style: { margin: '10px 0 0', color: 'var(--dsw-alias-label-tertiary, inherit)' } },
|
|
861
|
+
'The clone is added as a workspace. Sign-in comes from the GitHub CLI, so this plugin stores no token.')))
|
|
862
|
+
, document.body)
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* The footer row that opens the GitHub dialog.
|
|
867
|
+
* @param props - our lazy `configForms`.
|
|
868
|
+
* @returns the row, or null when the feature is switched off.
|
|
869
|
+
*/
|
|
870
|
+
function GithubEntry(props) {
|
|
871
|
+
const enabled = useSwitch(props.configForms, 'githubWorkspace', true)
|
|
872
|
+
if (!enabled) return null
|
|
873
|
+
return React.createElement('div', { style: { padding: '2px 8px 4px', minWidth: 0 } },
|
|
874
|
+
React.createElement('button', {
|
|
875
|
+
type: 'button',
|
|
876
|
+
'data-polish-github': 'entry',
|
|
877
|
+
title: 'Add a workspace from GitHub',
|
|
878
|
+
'aria-label': 'Add a workspace from GitHub',
|
|
879
|
+
onClick: () => githubStore.set(true),
|
|
880
|
+
style: {
|
|
881
|
+
display: 'flex',
|
|
882
|
+
alignItems: 'center',
|
|
883
|
+
gap: '6px',
|
|
884
|
+
maxWidth: '100%',
|
|
885
|
+
minWidth: 0,
|
|
886
|
+
padding: '2px 6px',
|
|
887
|
+
border: 'none',
|
|
888
|
+
borderRadius: 'var(--dsw-radius-sm, 6px)',
|
|
889
|
+
background: 'transparent',
|
|
890
|
+
color: 'var(--dsw-alias-label-tertiary, inherit)',
|
|
891
|
+
font: 'inherit',
|
|
892
|
+
fontSize: '12px',
|
|
893
|
+
lineHeight: '18px',
|
|
894
|
+
fontVariantNumeric: 'tabular-nums',
|
|
895
|
+
whiteSpace: 'nowrap',
|
|
896
|
+
overflow: 'hidden',
|
|
897
|
+
textOverflow: 'ellipsis',
|
|
898
|
+
cursor: 'pointer',
|
|
899
|
+
},
|
|
900
|
+
onMouseEnter: event => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
|
|
901
|
+
onMouseLeave: event => { event.currentTarget.style.background = 'transparent' },
|
|
902
|
+
},
|
|
903
|
+
React.createElement('svg', {
|
|
904
|
+
width: 14,
|
|
905
|
+
height: 14,
|
|
906
|
+
viewBox: '0 0 16 16',
|
|
907
|
+
fill: 'none',
|
|
908
|
+
stroke: 'currentColor',
|
|
909
|
+
strokeWidth: 1.2,
|
|
910
|
+
strokeLinecap: 'round',
|
|
911
|
+
strokeLinejoin: 'round',
|
|
912
|
+
'aria-hidden': 'true',
|
|
913
|
+
style: { flex: 'none' },
|
|
914
|
+
},
|
|
915
|
+
React.createElement('path', { d: 'M2.5 5.5h4l1.2 1.5h5.8v6.5h-11z' }),
|
|
916
|
+
React.createElement('path', { d: 'M8 9.8v3.4M6.3 11.5h3.4' })),
|
|
917
|
+
'Add from GitHub'))
|
|
918
|
+
}
|
|
919
|
+
|
|
619
920
|
return {
|
|
620
921
|
inject: ['slots'],
|
|
621
922
|
/**
|
|
@@ -678,5 +979,28 @@ return {
|
|
|
678
979
|
inject: () => ({ configForms, openFolder, workspaces: workspaceCtx.get('workspaces') }),
|
|
679
980
|
}, OpenFolderMenuItem))
|
|
680
981
|
})
|
|
982
|
+
// Adding a workspace from GitHub goes through the Workspace service's own
|
|
983
|
+
// create call — the same one the "+" menu makes once its folder picker
|
|
984
|
+
// returns a path — so a clone lands in the sidebar exactly like a picked
|
|
985
|
+
// folder does, with no Harness change anywhere. The service takes the plain
|
|
986
|
+
// path and throws on refusal; the Remote namespace underneath it is
|
|
987
|
+
// `ctx.remote.workspace` (singular), which is why this injects the service.
|
|
988
|
+
ctx.inject(['workspaces'], (githubCtx) => {
|
|
989
|
+
const createWorkspace = async (path) => {
|
|
990
|
+
const workspace = await githubCtx.workspaces.create({ path })
|
|
991
|
+
return workspace
|
|
992
|
+
}
|
|
993
|
+
githubCtx.slots.inject('shell.overlay', () => githubCtx.slots.register({
|
|
994
|
+
name: 'shell.overlay',
|
|
995
|
+
id: 'polish-github',
|
|
996
|
+
inject: () => ({ configForms, createWorkspace }),
|
|
997
|
+
}, GitHubPanel))
|
|
998
|
+
githubCtx.slots.inject('sidebar.footer.action', () => githubCtx.slots.register({
|
|
999
|
+
name: 'sidebar.footer.action',
|
|
1000
|
+
id: 'polish-github-entry',
|
|
1001
|
+
order: 4,
|
|
1002
|
+
inject: () => ({ configForms }),
|
|
1003
|
+
}, GithubEntry))
|
|
1004
|
+
})
|
|
681
1005
|
},
|
|
682
1006
|
}
|
package/client/switches.json
CHANGED
|
@@ -16,7 +16,13 @@
|
|
|
16
16
|
{
|
|
17
17
|
"key": "openWorkspaceFolder",
|
|
18
18
|
"label": "Open workspace folder",
|
|
19
|
-
"description": "Add an
|
|
19
|
+
"description": "Add an “Open workspace folder” row to a Session’s menu that opens its workspace in the file browser.",
|
|
20
|
+
"defaultOn": true
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"key": "githubWorkspace",
|
|
24
|
+
"label": "Add a workspace from GitHub",
|
|
25
|
+
"description": "Sign in with the GitHub CLI, browse your repositories, and clone one as a workspace.",
|
|
20
26
|
"defaultOn": true
|
|
21
27
|
}
|
|
22
28
|
]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# GitHub workspaces
|
|
2
|
+
|
|
3
|
+
**Add workspace from GitHub** turns a repository you can already reach with the
|
|
4
|
+
GitHub CLI into a workspace, without a second sign-in and without storing a
|
|
5
|
+
token.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
1. **Reads the CLI session.** The host half runs `gh auth token`. That is the
|
|
10
|
+
whole authentication story: no OAuth app, no device code, no token in a
|
|
11
|
+
settings file, no token in the browser. When `gh` is missing or signed out,
|
|
12
|
+
the dialog says so and names the command that fixes it (`gh auth login`).
|
|
13
|
+
2. **Lists what that session can clone.** `GET /user/repos` with
|
|
14
|
+
`affiliation=owner,collaborator,organization_member`, paged through the
|
|
15
|
+
`link` header, most recently updated first.
|
|
16
|
+
3. **Clones into your own folder.** `<home>\AryaCode\<owner>\<repo>`, created if
|
|
17
|
+
missing. A directory that is already the same repository is reused instead of
|
|
18
|
+
cloned again; a directory that is a *different* repository is an error, never
|
|
19
|
+
a silent overwrite.
|
|
20
|
+
4. **Creates the workspace.** The clone path is handed to the workspace
|
|
21
|
+
controller (`workspaces.create({ path })`), which is the same call the "+" menu
|
|
22
|
+
makes, so the new workspace behaves like any other.
|
|
23
|
+
|
|
24
|
+
## Where the token goes, and where it does not
|
|
25
|
+
|
|
26
|
+
The token is read on the Host and passed to `git` as an `http.extraHeader` value
|
|
27
|
+
in git's own environment (`GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_0`/
|
|
28
|
+
`GIT_CONFIG_VALUE_0`), never in the remote URL and never on the command line — so
|
|
29
|
+
it cannot leak through `ps`, a stored remote, or a shell history. GitHub's git
|
|
30
|
+
server rejects a bearer token for clone, which is why the header carries
|
|
31
|
+
`x-access-token` basic auth. Every line of git output is scrubbed of the token
|
|
32
|
+
before it can reach the browser, and the route answers `cache-control: no-store`.
|
|
33
|
+
|
|
34
|
+
## Why the entry point is the sidebar foot
|
|
35
|
+
|
|
36
|
+
The "+" menu's **Add workspace** entry only appears when something occupies
|
|
37
|
+
`sidebar.workspaces.directoryFlow`, and that slot is `kind: 'single'`: it already
|
|
38
|
+
holds the folder picker, so occupying it would mean re-implementing folder
|
|
39
|
+
choosing (browse *and* the native desktop picker) just to add a second source.
|
|
40
|
+
Rather than replace the shipped picker, the feature adds its own entry in the
|
|
41
|
+
sidebar foot next to it. If a multi-source workspace socket ever lands in the
|
|
42
|
+
harness, only the registration moves; the route, the clone rules and the tests
|
|
43
|
+
stay as they are.
|
|
44
|
+
|
|
45
|
+
## Not used from the AryaAI-era plugin
|
|
46
|
+
|
|
47
|
+
`@deepseek-ai/dsh-github` was read as a reference and deliberately not reused:
|
|
48
|
+
it is a typert remote service with generated protocol packages, and it
|
|
49
|
+
authenticates through an OAuth device flow that needs a registered OAuth app.
|
|
50
|
+
Its ideas that were kept: reject anything that is not one path segment, reuse a
|
|
51
|
+
checkout whose origin already matches, present the token as basic auth in git's
|
|
52
|
+
environment, page the repository list, and mark a repository that is already
|
|
53
|
+
cloned.
|
|
54
|
+
|
|
55
|
+
## Tests
|
|
56
|
+
|
|
57
|
+
`test/github.spec.mjs` covers the path rules (`.`, `..`, separators, empty),
|
|
58
|
+
the destination layout, origin comparison across https/ssh forms, token
|
|
59
|
+
scrubbing, the CLI token's three failure modes, signed-out and signed-in status,
|
|
60
|
+
the `cloned` marker against a real temporary tree, the spawn shape of a clone
|
|
61
|
+
(command, `--`, URL, destination, and the header in the environment rather than
|
|
62
|
+
in the arguments), the reuse path, the refusal to overwrite another repository,
|
|
63
|
+
and a failing clone whose message is scrubbed.
|
package/docs/workspace-folder.md
CHANGED
|
@@ -55,6 +55,12 @@ If the harness ever declares a workspace-scoped menu slot, this feature should m
|
|
|
55
55
|
there: the Host call and the path resolution stay exactly as they are, and only the
|
|
56
56
|
registration line changes.
|
|
57
57
|
|
|
58
|
+
**Owner's decision (2026-10-27):** asked again, after the Session row menu shipped,
|
|
59
|
+
whether to add that slot to the harness and release a harness patch, the owner
|
|
60
|
+
declined — "then forget it". The workspace row stays untouched and the feature stays
|
|
61
|
+
on the Session row menu. The question has been asked and answered; do not re-open it
|
|
62
|
+
without a new instruction.
|
|
63
|
+
|
|
58
64
|
## Evidence for the next reader
|
|
59
65
|
|
|
60
66
|
- Slot contract: `packages/client/ui-workspace/src/client/contract/slots.ts:152-205`.
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub workspaces: sign in through the GitHub CLI's existing session, list
|
|
3
|
+
* repositories, and clone one under the clone root so it can be adopted as a
|
|
4
|
+
* workspace.
|
|
5
|
+
*
|
|
6
|
+
* The browser half never sees the token. It posts to one route and gets back
|
|
7
|
+
* JSON; every GitHub call and every `git` invocation happens here.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn } from 'node:child_process'
|
|
11
|
+
import { mkdir, realpath, stat } from 'node:fs/promises'
|
|
12
|
+
import { homedir } from 'node:os'
|
|
13
|
+
import { dirname, join } from 'node:path'
|
|
14
|
+
|
|
15
|
+
/** Absolute path the Host registers; the browser addresses `api/polish.github`. */
|
|
16
|
+
export const GITHUB_ROUTE = '/api/polish.github'
|
|
17
|
+
|
|
18
|
+
/** Root a clone lands in: `<home>/AryaCode/<owner>/<repo>`. */
|
|
19
|
+
export function cloneRoot(home = homedir()) {
|
|
20
|
+
return join(home, 'AryaCode')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const SEGMENT = /^[A-Za-z0-9_.-]+$/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Reject a repository part that could escape the clone root.
|
|
27
|
+
* @param value - owner or repository name from GitHub.
|
|
28
|
+
* @returns the same value when it is one plain path segment.
|
|
29
|
+
*/
|
|
30
|
+
export function assertSegment(value) {
|
|
31
|
+
if (typeof value !== 'string' || !SEGMENT.test(value) || value === '.' || value === '..') {
|
|
32
|
+
throw new Error('repository name must be a single path segment')
|
|
33
|
+
}
|
|
34
|
+
return value
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Directory one repository clones into.
|
|
39
|
+
* @param owner - repository owner.
|
|
40
|
+
* @param name - repository name.
|
|
41
|
+
* @param home - home directory to root the default clone tree in.
|
|
42
|
+
* @returns absolute destination path.
|
|
43
|
+
*/
|
|
44
|
+
export function cloneDestination(owner, name, home = homedir()) {
|
|
45
|
+
return join(cloneRoot(home), assertSegment(owner), assertSegment(name))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether a git origin URL already points at this repository.
|
|
50
|
+
* @param origin - `git remote get-url origin` output.
|
|
51
|
+
* @param owner - repository owner.
|
|
52
|
+
* @param name - repository name.
|
|
53
|
+
* @returns true for either the https or the ssh form of the same repo.
|
|
54
|
+
*/
|
|
55
|
+
export function sameOrigin(origin, owner, name) {
|
|
56
|
+
const normalized = String(origin).trim().replace(/\.git$/i, '').replace(/\/+$/, '').toLowerCase()
|
|
57
|
+
return normalized === `https://github.com/${owner}/${name}`.toLowerCase()
|
|
58
|
+
|| normalized === `git@github.com:${owner}/${name}`.toLowerCase()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Run a program and collect its output. Never throws on a non-zero exit.
|
|
63
|
+
* @param command - executable name.
|
|
64
|
+
* @param args - arguments.
|
|
65
|
+
* @param options - extra environment and working directory.
|
|
66
|
+
* @returns exit code plus captured stdout and stderr.
|
|
67
|
+
*/
|
|
68
|
+
export function runCommand(command, args, options = {}) {
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
const child = spawn(command, args, {
|
|
71
|
+
cwd: options.cwd,
|
|
72
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never', ...options.env },
|
|
73
|
+
windowsHide: true,
|
|
74
|
+
})
|
|
75
|
+
let stdout = ''
|
|
76
|
+
let stderr = ''
|
|
77
|
+
child.stdout.setEncoding('utf8')
|
|
78
|
+
child.stderr.setEncoding('utf8')
|
|
79
|
+
child.stdout.on('data', chunk => { stdout += chunk })
|
|
80
|
+
child.stderr.on('data', chunk => { stderr += chunk })
|
|
81
|
+
child.on('error', reject)
|
|
82
|
+
child.on('close', code => { resolve({ code: code ?? 1, stdout, stderr }) })
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Remove the token from text that may reach the browser.
|
|
88
|
+
* @param text - command output.
|
|
89
|
+
* @param token - access token.
|
|
90
|
+
* @returns text with every occurrence replaced.
|
|
91
|
+
*/
|
|
92
|
+
export function scrubToken(text, token) {
|
|
93
|
+
if (token === undefined || token === '') return text
|
|
94
|
+
return String(text).split(token).join('[redacted]')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Ask the GitHub CLI for the token it already holds.
|
|
99
|
+
*
|
|
100
|
+
* Nothing is stored by this plugin: when `gh` is signed out, the feature says
|
|
101
|
+
* so instead of collecting a credential of its own.
|
|
102
|
+
* @param run - command runner, injected by tests.
|
|
103
|
+
* @returns the token, or undefined when `gh` is missing or signed out.
|
|
104
|
+
*/
|
|
105
|
+
export async function ghToken(run = runCommand) {
|
|
106
|
+
let answer
|
|
107
|
+
try {
|
|
108
|
+
answer = await run('gh', ['auth', 'token'])
|
|
109
|
+
} catch {
|
|
110
|
+
return undefined
|
|
111
|
+
}
|
|
112
|
+
if (answer.code !== 0) return undefined
|
|
113
|
+
const token = answer.stdout.trim()
|
|
114
|
+
return token === '' ? undefined : token
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Read the login that owns a token.
|
|
119
|
+
* @param token - GitHub access token.
|
|
120
|
+
* @param fetchImpl - HTTP implementation.
|
|
121
|
+
* @returns the login name.
|
|
122
|
+
*/
|
|
123
|
+
export async function fetchLogin(token, fetchImpl = fetch) {
|
|
124
|
+
const response = await fetchImpl('https://api.github.com/user', { headers: githubHeaders(token) })
|
|
125
|
+
if (!response.ok) throw new Error(`GitHub sign-in check failed (${String(response.status)})`)
|
|
126
|
+
const body = await response.json()
|
|
127
|
+
const login = body?.login
|
|
128
|
+
if (typeof login !== 'string' || login === '') throw new Error('GitHub did not return a login')
|
|
129
|
+
return login
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* List repositories the token can clone, most recently updated first.
|
|
134
|
+
* @param token - GitHub access token.
|
|
135
|
+
* @param fetchImpl - HTTP implementation.
|
|
136
|
+
* @param pages - most pages of 100 to read.
|
|
137
|
+
* @returns repository rows in list order.
|
|
138
|
+
*/
|
|
139
|
+
export async function fetchRepos(token, fetchImpl = fetch, pages = 3) {
|
|
140
|
+
const repos = []
|
|
141
|
+
let url = 'https://api.github.com/user/repos?per_page=100&sort=updated&affiliation=owner,collaborator,organization_member'
|
|
142
|
+
for (let page = 0; page < pages && url !== undefined; page += 1) {
|
|
143
|
+
const response = await fetchImpl(url, { headers: githubHeaders(token) })
|
|
144
|
+
if (!response.ok) throw new Error(`GitHub repository list failed (${String(response.status)})`)
|
|
145
|
+
const body = await response.json()
|
|
146
|
+
if (!Array.isArray(body)) throw new Error('GitHub repository list was not an array')
|
|
147
|
+
for (const item of body) {
|
|
148
|
+
const row = readRepo(item)
|
|
149
|
+
if (row !== undefined) repos.push(row)
|
|
150
|
+
}
|
|
151
|
+
url = nextLink(response.headers.get('link'))
|
|
152
|
+
}
|
|
153
|
+
return repos
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function githubHeaders(token) {
|
|
157
|
+
return {
|
|
158
|
+
accept: 'application/vnd.github+json',
|
|
159
|
+
authorization: `Bearer ${token}`,
|
|
160
|
+
'user-agent': 'aryacode-polish',
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function readRepo(value) {
|
|
165
|
+
if (value === null || typeof value !== 'object') return undefined
|
|
166
|
+
const name = value.name
|
|
167
|
+
const owner = value.owner !== null && typeof value.owner === 'object' ? value.owner.login : undefined
|
|
168
|
+
if (typeof name !== 'string' || typeof owner !== 'string') return undefined
|
|
169
|
+
return {
|
|
170
|
+
owner,
|
|
171
|
+
name,
|
|
172
|
+
fullName: typeof value.full_name === 'string' ? value.full_name : `${owner}/${name}`,
|
|
173
|
+
isPrivate: value.private === true,
|
|
174
|
+
isFork: value.fork === true,
|
|
175
|
+
description: typeof value.description === 'string' ? value.description : '',
|
|
176
|
+
updatedAt: typeof value.updated_at === 'string' ? value.updated_at : '',
|
|
177
|
+
defaultBranch: typeof value.default_branch === 'string' ? value.default_branch : '',
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function nextLink(header) {
|
|
182
|
+
if (typeof header !== 'string' || header === '') return undefined
|
|
183
|
+
for (const part of header.split(',')) {
|
|
184
|
+
const match = /<([^>]+)>;\s*rel="next"/.exec(part)
|
|
185
|
+
if (match?.[1] !== undefined) return match[1]
|
|
186
|
+
}
|
|
187
|
+
return undefined
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function directoryExists(path) {
|
|
191
|
+
try {
|
|
192
|
+
return (await stat(path)).isDirectory()
|
|
193
|
+
} catch {
|
|
194
|
+
return false
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function existingRealpath(path) {
|
|
199
|
+
try {
|
|
200
|
+
return await realpath(path)
|
|
201
|
+
} catch {
|
|
202
|
+
return undefined
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Clone a repository, or reuse a directory whose origin is already that repo.
|
|
208
|
+
*
|
|
209
|
+
* The token travels in git's environment as an http header, never in the
|
|
210
|
+
* remote URL and never on the command line, so it cannot leak through `ps` or
|
|
211
|
+
* a stored remote.
|
|
212
|
+
* @param owner - repository owner.
|
|
213
|
+
* @param name - repository name.
|
|
214
|
+
* @param token - GitHub access token.
|
|
215
|
+
* @param options - command runner, home directory and optional progress sink.
|
|
216
|
+
* @returns destination path and whether an existing checkout was kept.
|
|
217
|
+
*/
|
|
218
|
+
export async function cloneRepo(owner, name, token, options = {}) {
|
|
219
|
+
const run = options.run ?? runCommand
|
|
220
|
+
const path = cloneDestination(owner, name, options.home)
|
|
221
|
+
const progress = options.progress ?? (() => {})
|
|
222
|
+
if (await directoryExists(path)) {
|
|
223
|
+
const origin = await run('git', ['remote', 'get-url', 'origin'], { cwd: path })
|
|
224
|
+
if (origin.code === 0 && sameOrigin(origin.stdout, owner, name)) {
|
|
225
|
+
progress(`Reusing the existing checkout at ${path}`)
|
|
226
|
+
return { path, reused: true }
|
|
227
|
+
}
|
|
228
|
+
throw new Error(`${path} already exists and is not ${owner}/${name}`)
|
|
229
|
+
}
|
|
230
|
+
await mkdir(dirname(path), { recursive: true })
|
|
231
|
+
progress(`Cloning ${owner}/${name} into ${path}`)
|
|
232
|
+
// GitHub's git server rejects a bearer token, so it is presented as basic auth
|
|
233
|
+
// through an extra http header in git's own environment.
|
|
234
|
+
const header = `Authorization: Basic ${Buffer.from(`x-access-token:${token}`).toString('base64')}`
|
|
235
|
+
const cloned = await run(
|
|
236
|
+
'git',
|
|
237
|
+
['-c', 'credential.helper=', 'clone', '--progress', '--', `https://github.com/${owner}/${name}.git`, path],
|
|
238
|
+
{
|
|
239
|
+
env: {
|
|
240
|
+
GIT_CONFIG_COUNT: '1',
|
|
241
|
+
GIT_CONFIG_KEY_0: 'http.extraHeader',
|
|
242
|
+
GIT_CONFIG_VALUE_0: header,
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
)
|
|
246
|
+
if (cloned.code !== 0) {
|
|
247
|
+
throw new Error(scrubToken(cloned.stderr || cloned.stdout || 'git clone failed', token))
|
|
248
|
+
}
|
|
249
|
+
return { path, reused: false }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Answer one browser request.
|
|
254
|
+
* @param body - parsed request body.
|
|
255
|
+
* @param deps - injected GitHub and git implementations.
|
|
256
|
+
* @returns the JSON value the browser half renders.
|
|
257
|
+
*/
|
|
258
|
+
export async function handleRequest(body, deps = {}) {
|
|
259
|
+
const action = body?.action
|
|
260
|
+
const token = await (deps.token ?? ghToken)()
|
|
261
|
+
if (action === 'status') {
|
|
262
|
+
if (token === undefined) {
|
|
263
|
+
return {
|
|
264
|
+
ok: true,
|
|
265
|
+
value: { signedIn: false, login: '', root: cloneRoot(deps.home), reason: 'The GitHub CLI is missing or not signed in: run `gh auth login` in a terminal.' },
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const login = await (deps.login ?? fetchLogin)(token)
|
|
269
|
+
return { ok: true, value: { signedIn: true, login, root: cloneRoot(deps.home), reason: '' } }
|
|
270
|
+
}
|
|
271
|
+
if (token === undefined) {
|
|
272
|
+
return { ok: false, error: 'Sign in with the GitHub CLI first: run `gh auth login` in a terminal, then try again.' }
|
|
273
|
+
}
|
|
274
|
+
if (action === 'repos') {
|
|
275
|
+
const repos = await (deps.repos ?? fetchRepos)(token)
|
|
276
|
+
const marked = []
|
|
277
|
+
for (const repo of repos) {
|
|
278
|
+
const real = await existingRealpath(cloneDestination(repo.owner, repo.name, deps.home))
|
|
279
|
+
marked.push({ ...repo, cloned: real !== undefined })
|
|
280
|
+
}
|
|
281
|
+
return { ok: true, value: { root: cloneRoot(deps.home), repos: marked } }
|
|
282
|
+
}
|
|
283
|
+
if (action === 'clone') {
|
|
284
|
+
const owner = assertSegment(body.owner)
|
|
285
|
+
const name = assertSegment(body.name)
|
|
286
|
+
const result = await (deps.clone ?? cloneRepo)(owner, name, token, { home: deps.home })
|
|
287
|
+
return { ok: true, value: { path: result.path, reused: result.reused } }
|
|
288
|
+
}
|
|
289
|
+
return { ok: false, error: `unknown GitHub action: ${String(action)}` }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* JSON response helper.
|
|
294
|
+
* @param value - payload to serialize.
|
|
295
|
+
* @param status - HTTP status.
|
|
296
|
+
* @returns a JSON response.
|
|
297
|
+
*/
|
|
298
|
+
export function json(value, status = 200) {
|
|
299
|
+
return new Response(JSON.stringify(value), {
|
|
300
|
+
status,
|
|
301
|
+
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Mount the route on a context that provides the `connection` service. */
|
|
306
|
+
export const githubFeature = {
|
|
307
|
+
name: 'polish-github',
|
|
308
|
+
inject: ['connection'],
|
|
309
|
+
apply(ctx, config = {}) {
|
|
310
|
+
ctx.effect(() => ctx.connection.fetch.register({
|
|
311
|
+
path: GITHUB_ROUTE,
|
|
312
|
+
// Without a method list the platform would only route GET, which is why the
|
|
313
|
+
// browser's POST would fall through to the static handler as a 404.
|
|
314
|
+
methods: ['POST'],
|
|
315
|
+
requestBody: 'buffered',
|
|
316
|
+
fetch: async (request) => {
|
|
317
|
+
if (request.method !== 'POST') return json({ ok: false, error: 'POST only' }, 405)
|
|
318
|
+
let body
|
|
319
|
+
try {
|
|
320
|
+
body = await request.json()
|
|
321
|
+
} catch {
|
|
322
|
+
return json({ ok: false, error: 'body must be JSON' }, 400)
|
|
323
|
+
}
|
|
324
|
+
try {
|
|
325
|
+
const deps = {
|
|
326
|
+
home: typeof config.home === 'string' && config.home !== '' ? config.home : undefined,
|
|
327
|
+
}
|
|
328
|
+
return json(await handleRequest(body, deps))
|
|
329
|
+
} catch (error) {
|
|
330
|
+
const message = error instanceof Error ? error.message : 'GitHub request failed'
|
|
331
|
+
return json({ ok: false, error: message })
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
}))
|
|
335
|
+
},
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Register one child fiber carrying the GitHub route when a profile mounts the
|
|
340
|
+
* feature. Kept separate from the switch so turning the switch off removes the
|
|
341
|
+
* route together with every other surface.
|
|
342
|
+
* @param ctx - the row's context.
|
|
343
|
+
* @returns the mounted fiber.
|
|
344
|
+
*/
|
|
345
|
+
export function mountGithub(ctx, config = {}) {
|
|
346
|
+
return ctx.plugin({ ...githubFeature, config })
|
|
347
|
+
}
|
package/entries/registry.js
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import { mountCost } from './cost.js'
|
|
27
|
+
import { mountGithub } from './github.js'
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* Every feature this bundle ships. Spelling corrections are investigated and
|
|
@@ -52,6 +53,13 @@ export const features = [
|
|
|
52
53
|
description: 'Add an "Open workspace folder" row to a Session\u2019s menu that opens its workspace in the file browser.',
|
|
53
54
|
defaultOn: true,
|
|
54
55
|
},
|
|
56
|
+
{
|
|
57
|
+
id: 'githubWorkspace',
|
|
58
|
+
label: 'Add a workspace from GitHub',
|
|
59
|
+
description: 'Sign in with the GitHub CLI, browse your repositories, and clone one as a workspace.',
|
|
60
|
+
defaultOn: true,
|
|
61
|
+
mount: mountGithub,
|
|
62
|
+
},
|
|
55
63
|
]
|
|
56
64
|
|
|
57
65
|
/**
|
package/lib/client.js
CHANGED
|
@@ -25,11 +25,17 @@ 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
|
|
28
34
|
}
|
|
29
35
|
]
|
|
30
36
|
const ROW_ID = "polish"
|
|
31
37
|
const ROW_KEY = "@aryacode/dsh-aryacode-polish" + '#' + ROW_ID
|
|
32
|
-
const CLIENT_VERSION = "0.
|
|
38
|
+
const CLIENT_VERSION = "0.5.0"
|
|
33
39
|
|
|
34
40
|
/**
|
|
35
41
|
* The two pure decisions behind the balance line: which wallet to show, and how
|
|
@@ -685,22 +691,24 @@ window.__ModuleLoader__.load({
|
|
|
685
691
|
return React.createElement('div', { 'data-polish-balance': 'empty', style: { display: 'none' } })
|
|
686
692
|
}
|
|
687
693
|
const money = formatBalance(wallet)
|
|
694
|
+
// The foot is a single row shared with the GitHub entry, so the amount stays
|
|
695
|
+
// bare: a tooltip names it, and nothing here competes for width.
|
|
688
696
|
return React.createElement('div', {
|
|
689
697
|
title: 'Remaining account balance',
|
|
698
|
+
'data-polish-balance': 'ready',
|
|
690
699
|
style: {
|
|
691
700
|
display: 'flex',
|
|
692
701
|
alignItems: 'center',
|
|
693
702
|
gap: '6px',
|
|
694
|
-
padding: '2px
|
|
703
|
+
padding: '2px 4px 4px 8px',
|
|
695
704
|
fontSize: '12px',
|
|
696
705
|
lineHeight: '16px',
|
|
697
|
-
|
|
698
|
-
overflow: 'hidden',
|
|
706
|
+
flex: '0 0 auto',
|
|
699
707
|
whiteSpace: 'nowrap',
|
|
700
|
-
textOverflow: 'ellipsis',
|
|
701
708
|
opacity: 0.75,
|
|
709
|
+
fontVariantNumeric: 'tabular-nums',
|
|
702
710
|
},
|
|
703
|
-
},
|
|
711
|
+
}, money)
|
|
704
712
|
}
|
|
705
713
|
|
|
706
714
|
/**
|
|
@@ -771,6 +779,305 @@ window.__ModuleLoader__.load({
|
|
|
771
779
|
return React.createElement('button', { type: 'button', role: 'menuitem', onClick: onSelect }, label)
|
|
772
780
|
}
|
|
773
781
|
|
|
782
|
+
/** The Host route the GitHub halves talk over. Document-relative, like every app route. */
|
|
783
|
+
const GITHUB_ROUTE = 'api/polish.github'
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Whether the GitHub panel is open. One store for the whole bundle: the footer
|
|
787
|
+
* row opens it, and the overlay panel is the only reader.
|
|
788
|
+
*/
|
|
789
|
+
const githubStore = (() => {
|
|
790
|
+
let open = false
|
|
791
|
+
const listeners = new Set()
|
|
792
|
+
return {
|
|
793
|
+
isOpen: () => open,
|
|
794
|
+
subscribe(listener) {
|
|
795
|
+
listeners.add(listener)
|
|
796
|
+
return () => { listeners.delete(listener) }
|
|
797
|
+
},
|
|
798
|
+
set(next) {
|
|
799
|
+
if (open === next) return
|
|
800
|
+
open = next
|
|
801
|
+
for (const listener of listeners) listener()
|
|
802
|
+
},
|
|
803
|
+
}
|
|
804
|
+
})()
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Post one action to the GitHub route.
|
|
808
|
+
* @param action - `status`, `repos` or `clone`.
|
|
809
|
+
* @param extra - fields the action needs.
|
|
810
|
+
* @returns the route's `value`, or throws with its own message.
|
|
811
|
+
*/
|
|
812
|
+
async function githubCall(action, extra) {
|
|
813
|
+
const response = await fetch(GITHUB_ROUTE, {
|
|
814
|
+
method: 'POST',
|
|
815
|
+
headers: { 'content-type': 'application/json' },
|
|
816
|
+
body: JSON.stringify({ action, ...(extra ?? {}) }),
|
|
817
|
+
})
|
|
818
|
+
const payload = await response.json().catch(() => undefined)
|
|
819
|
+
if (payload === undefined || typeof payload !== 'object') {
|
|
820
|
+
throw new Error(`the Host answered ${String(response.status)}`)
|
|
821
|
+
}
|
|
822
|
+
if (payload.ok !== true) throw new Error(payload.error || 'GitHub request failed')
|
|
823
|
+
return payload.value
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/** Modal backdrop and panel skin, matching the Shell's own dialogs. */
|
|
827
|
+
const GITHUB_BACKDROP_STYLE = {
|
|
828
|
+
position: 'fixed',
|
|
829
|
+
inset: 0,
|
|
830
|
+
zIndex: 1200,
|
|
831
|
+
display: 'flex',
|
|
832
|
+
alignItems: 'center',
|
|
833
|
+
justifyContent: 'center',
|
|
834
|
+
background: 'rgba(16, 18, 22, 0.32)',
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const GITHUB_CARD_STYLE = {
|
|
838
|
+
...PANEL_STYLE,
|
|
839
|
+
position: 'static',
|
|
840
|
+
width: 'min(560px, calc(100vw - 32px))',
|
|
841
|
+
maxHeight: 'min(560px, calc(100vh - 64px))',
|
|
842
|
+
display: 'flex',
|
|
843
|
+
flexDirection: 'column',
|
|
844
|
+
overflow: 'hidden',
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const GITHUB_LIST_STYLE = {
|
|
848
|
+
marginTop: '10px',
|
|
849
|
+
overflowY: 'auto',
|
|
850
|
+
borderTop: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.12))',
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const GITHUB_ROW_STYLE = {
|
|
854
|
+
display: 'flex',
|
|
855
|
+
alignItems: 'center',
|
|
856
|
+
gap: '8px',
|
|
857
|
+
width: '100%',
|
|
858
|
+
padding: '8px 4px',
|
|
859
|
+
border: 'none',
|
|
860
|
+
background: 'transparent',
|
|
861
|
+
color: 'inherit',
|
|
862
|
+
font: 'inherit',
|
|
863
|
+
textAlign: 'left',
|
|
864
|
+
cursor: 'pointer',
|
|
865
|
+
borderBottom: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.08))',
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const GITHUB_INPUT_STYLE = {
|
|
869
|
+
width: '100%',
|
|
870
|
+
boxSizing: 'border-box',
|
|
871
|
+
padding: '6px 8px',
|
|
872
|
+
font: 'inherit',
|
|
873
|
+
color: 'inherit',
|
|
874
|
+
background: 'transparent',
|
|
875
|
+
border: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.2))',
|
|
876
|
+
borderRadius: 'var(--dsw-radius-sm, 6px)',
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* The add-a-workspace-from-GitHub dialog.
|
|
881
|
+
*
|
|
882
|
+
* Sign-in is the GitHub CLI's own session: the Host asks `gh` for the token it
|
|
883
|
+
* already holds, so this dialog never collects a credential. A clone becomes a
|
|
884
|
+
* workspace through the Workspace Remote, which is the same call the "+" menu
|
|
885
|
+
* makes after its folder picker returns a path.
|
|
886
|
+
*
|
|
887
|
+
* @param props - our lazy `configForms` and the `createWorkspace` opener.
|
|
888
|
+
* @returns the dialog, or null while it is closed or switched off.
|
|
889
|
+
*/
|
|
890
|
+
function GitHubPanel(props) {
|
|
891
|
+
const open = React.useSyncExternalStore(githubStore.subscribe, githubStore.isOpen)
|
|
892
|
+
const enabled = useSwitch(props.configForms, 'githubWorkspace', true)
|
|
893
|
+
const [phase, setPhase] = React.useState('idle')
|
|
894
|
+
const [account, setAccount] = React.useState(undefined)
|
|
895
|
+
const [repos, setRepos] = React.useState([])
|
|
896
|
+
const [query, setQuery] = React.useState('')
|
|
897
|
+
const [error, setError] = React.useState('')
|
|
898
|
+
const [busy, setBusy] = React.useState('')
|
|
899
|
+
|
|
900
|
+
React.useEffect(() => {
|
|
901
|
+
if (!open || !enabled) return undefined
|
|
902
|
+
let live = true
|
|
903
|
+
setPhase('loading')
|
|
904
|
+
setError('')
|
|
905
|
+
setBusy('')
|
|
906
|
+
setQuery('')
|
|
907
|
+
Promise.resolve()
|
|
908
|
+
.then(() => githubCall('status'))
|
|
909
|
+
.then(status => {
|
|
910
|
+
if (!live) return undefined
|
|
911
|
+
setAccount(status)
|
|
912
|
+
if (status.signedIn !== true) return undefined
|
|
913
|
+
return githubCall('repos').then(list => {
|
|
914
|
+
if (!live) return
|
|
915
|
+
setRepos(Array.isArray(list.repos) ? list.repos : [])
|
|
916
|
+
})
|
|
917
|
+
})
|
|
918
|
+
.then(() => { if (live) setPhase('ready') })
|
|
919
|
+
.catch(reason => {
|
|
920
|
+
if (!live) return
|
|
921
|
+
setError(reason instanceof Error ? reason.message : String(reason))
|
|
922
|
+
setPhase('ready')
|
|
923
|
+
})
|
|
924
|
+
return () => { live = false }
|
|
925
|
+
}, [open, enabled])
|
|
926
|
+
|
|
927
|
+
React.useEffect(() => {
|
|
928
|
+
if (!open) return undefined
|
|
929
|
+
const onKey = event => { if (event.key === 'Escape') githubStore.set(false) }
|
|
930
|
+
document.addEventListener('keydown', onKey)
|
|
931
|
+
return () => document.removeEventListener('keydown', onKey)
|
|
932
|
+
}, [open])
|
|
933
|
+
|
|
934
|
+
if (!open || !enabled) return null
|
|
935
|
+
|
|
936
|
+
const signedIn = account !== undefined && account.signedIn === true
|
|
937
|
+
const needle = query.trim().toLowerCase()
|
|
938
|
+
const visible = needle === ''
|
|
939
|
+
? repos
|
|
940
|
+
: repos.filter(repo => `${repo.fullName} ${repo.description}`.toLowerCase().includes(needle))
|
|
941
|
+
const root = account !== undefined && typeof account.root === 'string' ? account.root : ''
|
|
942
|
+
|
|
943
|
+
const clone = repo => {
|
|
944
|
+
if (busy !== '') return
|
|
945
|
+
setBusy(repo.fullName)
|
|
946
|
+
setError('')
|
|
947
|
+
Promise.resolve()
|
|
948
|
+
.then(() => githubCall('clone', { owner: repo.owner, name: repo.name }))
|
|
949
|
+
.then(result => Promise.resolve(props.createWorkspace(result.path)).then(() => result))
|
|
950
|
+
.then(result => {
|
|
951
|
+
setBusy('')
|
|
952
|
+
githubStore.set(false)
|
|
953
|
+
if (typeof console !== 'undefined' && typeof console.info === 'function') {
|
|
954
|
+
console.info('[aryacode-polish] workspace ready:', result.path)
|
|
955
|
+
}
|
|
956
|
+
})
|
|
957
|
+
.catch(reason => {
|
|
958
|
+
setBusy('')
|
|
959
|
+
setError(reason instanceof Error ? reason.message : String(reason))
|
|
960
|
+
})
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
const rows = visible.slice(0, 200).map(repo => React.createElement('button', {
|
|
964
|
+
key: repo.fullName,
|
|
965
|
+
type: 'button',
|
|
966
|
+
style: GITHUB_ROW_STYLE,
|
|
967
|
+
disabled: busy !== '',
|
|
968
|
+
onClick: () => clone(repo),
|
|
969
|
+
title: repo.description === '' ? repo.fullName : `${repo.fullName} — ${repo.description}`,
|
|
970
|
+
'data-polish-github-row': repo.fullName,
|
|
971
|
+
},
|
|
972
|
+
React.createElement('span', { style: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
|
|
973
|
+
repo.fullName,
|
|
974
|
+
repo.isPrivate ? React.createElement('span', { style: { marginLeft: '6px', opacity: 0.6 } }, 'private') : null,
|
|
975
|
+
repo.isFork ? React.createElement('span', { style: { marginLeft: '6px', opacity: 0.6 } }, 'fork') : null),
|
|
976
|
+
repo.cloned === true
|
|
977
|
+
? React.createElement('span', { style: { opacity: 0.6 } }, 'cloned')
|
|
978
|
+
: null,
|
|
979
|
+
busy === repo.fullName ? React.createElement('span', { style: { opacity: 0.7 } }, 'cloning…') : null))
|
|
980
|
+
|
|
981
|
+
return ReactDOM.createPortal(
|
|
982
|
+
React.createElement('div', {
|
|
983
|
+
style: GITHUB_BACKDROP_STYLE,
|
|
984
|
+
'data-polish-github': 'dialog',
|
|
985
|
+
onPointerDown: event => { if (event.target === event.currentTarget) githubStore.set(false) },
|
|
986
|
+
},
|
|
987
|
+
React.createElement('div', { style: GITHUB_CARD_STYLE, role: 'dialog', 'aria-label': 'Add a workspace from GitHub' },
|
|
988
|
+
React.createElement('div', { style: { ...PANEL_TITLE_STYLE, marginBottom: '4px' } },
|
|
989
|
+
React.createElement('span', null, 'Add a workspace from GitHub'),
|
|
990
|
+
React.createElement('button', {
|
|
991
|
+
type: 'button',
|
|
992
|
+
onClick: () => githubStore.set(false),
|
|
993
|
+
style: { border: 'none', background: 'transparent', color: 'inherit', font: 'inherit', cursor: 'pointer', opacity: 0.7 },
|
|
994
|
+
'aria-label': 'Close',
|
|
995
|
+
}, '×')),
|
|
996
|
+
React.createElement('div', { style: { opacity: 0.75, minHeight: '18px' } },
|
|
997
|
+
phase === 'loading' && !signedIn ? 'Checking the GitHub CLI session…' : null,
|
|
998
|
+
signedIn ? `Signed in as ${String(account.login)} · cloning into ${root}` : null,
|
|
999
|
+
!signedIn && phase !== 'loading'
|
|
1000
|
+
? 'The GitHub CLI is not signed in. Run `gh auth login` in a terminal, then reopen this dialog.'
|
|
1001
|
+
: null),
|
|
1002
|
+
signedIn
|
|
1003
|
+
? React.createElement('input', {
|
|
1004
|
+
style: { ...GITHUB_INPUT_STYLE, marginTop: '10px' },
|
|
1005
|
+
value: query,
|
|
1006
|
+
placeholder: 'Search your repositories',
|
|
1007
|
+
'aria-label': 'Search your repositories',
|
|
1008
|
+
autoFocus: true,
|
|
1009
|
+
onChange: event => setQuery(event.target.value),
|
|
1010
|
+
})
|
|
1011
|
+
: null,
|
|
1012
|
+
error !== ''
|
|
1013
|
+
? React.createElement('div', { style: { marginTop: '10px', color: 'var(--dsw-alias-label-error, #b42318)' } }, error)
|
|
1014
|
+
: null,
|
|
1015
|
+
signedIn
|
|
1016
|
+
? React.createElement('div', { style: GITHUB_LIST_STYLE, 'data-polish-github-list': String(visible.length) },
|
|
1017
|
+
phase === 'loading' && visible.length === 0 ? React.createElement('div', { style: { padding: '10px 4px', opacity: 0.7 } }, 'Loading repositories…') : null,
|
|
1018
|
+
phase === 'ready' && visible.length === 0 ? React.createElement('div', { style: { padding: '10px 4px', opacity: 0.7 } }, 'No repository matches that search.') : null,
|
|
1019
|
+
...rows)
|
|
1020
|
+
: null,
|
|
1021
|
+
React.createElement('p', { style: { margin: '10px 0 0', color: 'var(--dsw-alias-label-tertiary, inherit)' } },
|
|
1022
|
+
'The clone is added as a workspace. Sign-in comes from the GitHub CLI, so this plugin stores no token.')))
|
|
1023
|
+
, document.body)
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* The footer row that opens the GitHub dialog.
|
|
1028
|
+
* @param props - our lazy `configForms`.
|
|
1029
|
+
* @returns the row, or null when the feature is switched off.
|
|
1030
|
+
*/
|
|
1031
|
+
function GithubEntry(props) {
|
|
1032
|
+
const enabled = useSwitch(props.configForms, 'githubWorkspace', true)
|
|
1033
|
+
if (!enabled) return null
|
|
1034
|
+
return React.createElement('div', { style: { padding: '2px 8px 4px', minWidth: 0 } },
|
|
1035
|
+
React.createElement('button', {
|
|
1036
|
+
type: 'button',
|
|
1037
|
+
'data-polish-github': 'entry',
|
|
1038
|
+
title: 'Add a workspace from GitHub',
|
|
1039
|
+
'aria-label': 'Add a workspace from GitHub',
|
|
1040
|
+
onClick: () => githubStore.set(true),
|
|
1041
|
+
style: {
|
|
1042
|
+
display: 'flex',
|
|
1043
|
+
alignItems: 'center',
|
|
1044
|
+
gap: '6px',
|
|
1045
|
+
maxWidth: '100%',
|
|
1046
|
+
minWidth: 0,
|
|
1047
|
+
padding: '2px 6px',
|
|
1048
|
+
border: 'none',
|
|
1049
|
+
borderRadius: 'var(--dsw-radius-sm, 6px)',
|
|
1050
|
+
background: 'transparent',
|
|
1051
|
+
color: 'var(--dsw-alias-label-tertiary, inherit)',
|
|
1052
|
+
font: 'inherit',
|
|
1053
|
+
fontSize: '12px',
|
|
1054
|
+
lineHeight: '18px',
|
|
1055
|
+
fontVariantNumeric: 'tabular-nums',
|
|
1056
|
+
whiteSpace: 'nowrap',
|
|
1057
|
+
overflow: 'hidden',
|
|
1058
|
+
textOverflow: 'ellipsis',
|
|
1059
|
+
cursor: 'pointer',
|
|
1060
|
+
},
|
|
1061
|
+
onMouseEnter: event => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
|
|
1062
|
+
onMouseLeave: event => { event.currentTarget.style.background = 'transparent' },
|
|
1063
|
+
},
|
|
1064
|
+
React.createElement('svg', {
|
|
1065
|
+
width: 14,
|
|
1066
|
+
height: 14,
|
|
1067
|
+
viewBox: '0 0 16 16',
|
|
1068
|
+
fill: 'none',
|
|
1069
|
+
stroke: 'currentColor',
|
|
1070
|
+
strokeWidth: 1.2,
|
|
1071
|
+
strokeLinecap: 'round',
|
|
1072
|
+
strokeLinejoin: 'round',
|
|
1073
|
+
'aria-hidden': 'true',
|
|
1074
|
+
style: { flex: 'none' },
|
|
1075
|
+
},
|
|
1076
|
+
React.createElement('path', { d: 'M2.5 5.5h4l1.2 1.5h5.8v6.5h-11z' }),
|
|
1077
|
+
React.createElement('path', { d: 'M8 9.8v3.4M6.3 11.5h3.4' })),
|
|
1078
|
+
'Add from GitHub'))
|
|
1079
|
+
}
|
|
1080
|
+
|
|
774
1081
|
return {
|
|
775
1082
|
inject: ['slots'],
|
|
776
1083
|
/**
|
|
@@ -833,6 +1140,29 @@ window.__ModuleLoader__.load({
|
|
|
833
1140
|
inject: () => ({ configForms, openFolder, workspaces: workspaceCtx.get('workspaces') }),
|
|
834
1141
|
}, OpenFolderMenuItem))
|
|
835
1142
|
})
|
|
1143
|
+
// Adding a workspace from GitHub goes through the Workspace service's own
|
|
1144
|
+
// create call — the same one the "+" menu makes once its folder picker
|
|
1145
|
+
// returns a path — so a clone lands in the sidebar exactly like a picked
|
|
1146
|
+
// folder does, with no Harness change anywhere. The service takes the plain
|
|
1147
|
+
// path and throws on refusal; the Remote namespace underneath it is
|
|
1148
|
+
// `ctx.remote.workspace` (singular), which is why this injects the service.
|
|
1149
|
+
ctx.inject(['workspaces'], (githubCtx) => {
|
|
1150
|
+
const createWorkspace = async (path) => {
|
|
1151
|
+
const workspace = await githubCtx.workspaces.create({ path })
|
|
1152
|
+
return workspace
|
|
1153
|
+
}
|
|
1154
|
+
githubCtx.slots.inject('shell.overlay', () => githubCtx.slots.register({
|
|
1155
|
+
name: 'shell.overlay',
|
|
1156
|
+
id: 'polish-github',
|
|
1157
|
+
inject: () => ({ configForms, createWorkspace }),
|
|
1158
|
+
}, GitHubPanel))
|
|
1159
|
+
githubCtx.slots.inject('sidebar.footer.action', () => githubCtx.slots.register({
|
|
1160
|
+
name: 'sidebar.footer.action',
|
|
1161
|
+
id: 'polish-github-entry',
|
|
1162
|
+
order: 4,
|
|
1163
|
+
inject: () => ({ configForms }),
|
|
1164
|
+
}, GithubEntry))
|
|
1165
|
+
})
|
|
836
1166
|
},
|
|
837
1167
|
}
|
|
838
1168
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aryacode/dsh-aryacode-polish",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "AryaCode Polish: one install for the small things that make AryaCode feel finished. One Plugins row, one switch per quality-of-life feature.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"scripts": {
|
|
52
52
|
"build:client": "node scripts/build-client.mjs",
|
|
53
53
|
"prepack": "node scripts/build-client.mjs",
|
|
54
|
-
"test": "node --test test/patch.spec.mjs test/config.spec.mjs test/registry.spec.mjs test/cost.spec.mjs test/money.spec.mjs test/account.spec.mjs test/docs.spec.mjs test/manifests.spec.mjs test/client-face.spec.mjs"
|
|
54
|
+
"test": "node --test test/patch.spec.mjs test/config.spec.mjs test/registry.spec.mjs test/cost.spec.mjs test/money.spec.mjs test/account.spec.mjs test/github.spec.mjs test/docs.spec.mjs test/manifests.spec.mjs test/client-face.spec.mjs"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
57
|
"zod": "^4.4.3"
|