@aryacode/dsh-aryacode-polish 0.5.0 → 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 +28 -0
- package/README.md +3 -2
- package/client/face.js +360 -43
- package/client/switches.json +6 -0
- package/docs/github-workspaces.md +28 -10
- package/entries/folders.js +115 -0
- package/entries/registry.js +8 -0
- package/lib/client.js +367 -44
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.6.0] - 2026-10-28
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- **Adding a workspace moved into the Workspaces "+" menu.** A new switch
|
|
8
|
+
(`workspaceSources`) occupies `sidebar.workspaces.directoryFlow`, which is what
|
|
9
|
+
makes the Shell's **Add workspace…** row appear at all, and stands in for the
|
|
10
|
+
folder picker with the source menu the owner asked for: **On this PC** (a folder
|
|
11
|
+
browser driven by this bundle's own `api/polish.folders` route) and **GitHub
|
|
12
|
+
repository…** (the existing dialog). A finished clone hands its path to the
|
|
13
|
+
flow's `onPicked`, so the Shell creates the workspace exactly as it does for a
|
|
14
|
+
picked folder.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- The sidebar foot now carries the GitHub sign-in state next to the balance
|
|
19
|
+
(`GitHub · <login>` / `GitHub · signed out`); clicking it opens the repository
|
|
20
|
+
dialog. It replaced the foot's add-workspace row, which the "+" menu supersedes.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- "On this PC" lists folders instead of waiting on a chooser that cannot open. A
|
|
25
|
+
composed directory picker serves one capability, `browse` or `native`, and the
|
|
26
|
+
browser build composes the one with no chooser to show, so the previous
|
|
27
|
+
`directoryPicker.pick()` call simply never settled. The flow now walks folders
|
|
28
|
+
through its own route: roots, a `..` row for going back, directories only, and
|
|
29
|
+
hidden or system entries filtered out.
|
|
30
|
+
|
|
3
31
|
## [0.5.0] - 2026-10-27
|
|
4
32
|
|
|
5
33
|
### Added
|
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: **five 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,7 +33,8 @@ 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 |
|
|
36
|
+
| **Add a workspace from GitHub** (`githubWorkspace`) | On | Adds the GitHub half of the workspace sources: 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. The sidebar foot carries the sign-in state (`GitHub · <login>`). See [GitHub workspaces](docs/github-workspaces.md). |
|
|
37
|
+
| **Add a workspace from this PC or GitHub** (`workspaceSources`) | On | The Workspaces **+** button gets a source menu: **On this PC** (the Host's OS chooser, or a folder list when the composition has no native chooser) and **GitHub repository…**. Off, the **+** button goes back to the shipped folder picker. |
|
|
37
38
|
|
|
38
39
|
### How the cost is computed
|
|
39
40
|
|
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
|
*
|
|
@@ -627,7 +628,12 @@ const GITHUB_ROUTE = 'api/polish.github'
|
|
|
627
628
|
*/
|
|
628
629
|
const githubStore = (() => {
|
|
629
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
|
|
630
635
|
const listeners = new Set()
|
|
636
|
+
const publish = () => { for (const listener of listeners) listener() }
|
|
631
637
|
return {
|
|
632
638
|
isOpen: () => open,
|
|
633
639
|
subscribe(listener) {
|
|
@@ -637,7 +643,21 @@ const githubStore = (() => {
|
|
|
637
643
|
set(next) {
|
|
638
644
|
if (open === next) return
|
|
639
645
|
open = next
|
|
640
|
-
|
|
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
|
|
641
661
|
},
|
|
642
662
|
}
|
|
643
663
|
})()
|
|
@@ -662,6 +682,28 @@ async function githubCall(action, extra) {
|
|
|
662
682
|
return payload.value
|
|
663
683
|
}
|
|
664
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
|
+
|
|
665
707
|
/** Modal backdrop and panel skin, matching the Shell's own dialogs. */
|
|
666
708
|
const GITHUB_BACKDROP_STYLE = {
|
|
667
709
|
position: 'fixed',
|
|
@@ -785,7 +827,15 @@ function GitHubPanel(props) {
|
|
|
785
827
|
setError('')
|
|
786
828
|
Promise.resolve()
|
|
787
829
|
.then(() => githubCall('clone', { owner: repo.owner, name: repo.name }))
|
|
788
|
-
|
|
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
|
+
})
|
|
789
839
|
.then(result => {
|
|
790
840
|
setBusy('')
|
|
791
841
|
githubStore.set(false)
|
|
@@ -862,59 +912,309 @@ function GitHubPanel(props) {
|
|
|
862
912
|
, document.body)
|
|
863
913
|
}
|
|
864
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
|
+
|
|
865
937
|
/**
|
|
866
|
-
* The footer
|
|
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
|
+
*
|
|
867
943
|
* @param props - our lazy `configForms`.
|
|
868
944
|
* @returns the row, or null when the feature is switched off.
|
|
869
945
|
*/
|
|
870
|
-
function
|
|
946
|
+
function GithubStatus(props) {
|
|
871
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])
|
|
872
966
|
if (!enabled) return null
|
|
873
|
-
|
|
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 } },
|
|
874
972
|
React.createElement('button', {
|
|
875
973
|
type: 'button',
|
|
876
|
-
'data-polish-github': '
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
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',
|
|
880
1183
|
style: {
|
|
881
1184
|
display: 'flex',
|
|
882
|
-
|
|
883
|
-
gap: '
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
padding: '
|
|
887
|
-
|
|
888
|
-
borderRadius: 'var(--dsw-radius-
|
|
889
|
-
background: '
|
|
890
|
-
|
|
891
|
-
font: 'inherit',
|
|
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))',
|
|
892
1194
|
fontSize: '12px',
|
|
893
1195
|
lineHeight: '18px',
|
|
894
|
-
|
|
895
|
-
whiteSpace: 'nowrap',
|
|
896
|
-
overflow: 'hidden',
|
|
897
|
-
textOverflow: 'ellipsis',
|
|
898
|
-
cursor: 'pointer',
|
|
1196
|
+
color: 'var(--dsw-alias-label-secondary, inherit)',
|
|
899
1197
|
},
|
|
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
1198
|
},
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
React.createElement('
|
|
916
|
-
|
|
917
|
-
|
|
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)
|
|
918
1218
|
}
|
|
919
1219
|
|
|
920
1220
|
return {
|
|
@@ -995,12 +1295,29 @@ return {
|
|
|
995
1295
|
id: 'polish-github',
|
|
996
1296
|
inject: () => ({ configForms, createWorkspace }),
|
|
997
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.
|
|
998
1300
|
githubCtx.slots.inject('sidebar.footer.action', () => githubCtx.slots.register({
|
|
999
1301
|
name: 'sidebar.footer.action',
|
|
1000
|
-
id: 'polish-github-
|
|
1302
|
+
id: 'polish-github-status',
|
|
1001
1303
|
order: 4,
|
|
1002
1304
|
inject: () => ({ configForms }),
|
|
1003
|
-
},
|
|
1305
|
+
}, GithubStatus))
|
|
1004
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))
|
|
1005
1322
|
},
|
|
1006
1323
|
}
|
package/client/switches.json
CHANGED
|
@@ -24,6 +24,12 @@
|
|
|
24
24
|
"label": "Add a workspace from GitHub",
|
|
25
25
|
"description": "Sign in with the GitHub CLI, browse your repositories, and clone one as a workspace.",
|
|
26
26
|
"defaultOn": true
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"key": "workspaceSources",
|
|
30
|
+
"label": "Add a workspace from this PC or GitHub",
|
|
31
|
+
"description": "Put an On this PC / GitHub repository source menu behind the Workspaces + button.",
|
|
32
|
+
"defaultOn": true
|
|
27
33
|
}
|
|
28
34
|
]
|
|
29
35
|
}
|
|
@@ -31,16 +31,34 @@ server rejects a bearer token for clone, which is why the header carries
|
|
|
31
31
|
`x-access-token` basic auth. Every line of git output is scrubbed of the token
|
|
32
32
|
before it can reach the browser, and the route answers `cache-control: no-store`.
|
|
33
33
|
|
|
34
|
-
##
|
|
35
|
-
|
|
36
|
-
The "+" menu's **Add workspace
|
|
37
|
-
`sidebar.workspaces.directoryFlow`, and that slot is `kind: 'single'
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
34
|
+
## How the entry point works
|
|
35
|
+
|
|
36
|
+
The "+" menu's **Add workspace…** entry only appears when something occupies
|
|
37
|
+
`sidebar.workspaces.directoryFlow`, and that slot is `kind: 'single'` — whoever
|
|
38
|
+
takes it stands in for the folder picker. This bundle takes it and offers the
|
|
39
|
+
two sources the owner asked for:
|
|
40
|
+
|
|
41
|
+
- **On this PC** — a folder browser driven by this bundle's own route
|
|
42
|
+
(`api/polish.folders`, actions `roots` and `ls`): roots, a `..` row for going
|
|
43
|
+
back, directories only, hidden and system entries filtered out, and **Use this
|
|
44
|
+
folder** hands the current path to the flow's `onPicked`.
|
|
45
|
+
- **GitHub repository…** — the dialog described above. It opens on behalf of the
|
|
46
|
+
flow, so a finished clone hands its path back through the flow's `onPicked`,
|
|
47
|
+
and the Shell creates the workspace exactly as it does for a picked folder.
|
|
48
|
+
|
|
49
|
+
### Why the browser does not borrow the Host's chooser
|
|
50
|
+
|
|
51
|
+
A composed directory picker serves exactly one capability: `browse` answers
|
|
52
|
+
`list` and `createDirectory`, `native` answers `pick`. The web profile composes
|
|
53
|
+
the picker whose chooser cannot open there, so `directoryPicker.pick()` waits on
|
|
54
|
+
a dialog that never appears — the first build of this flow did exactly that and
|
|
55
|
+
the row spun forever. Owning the listing keeps one behaviour in the app and in
|
|
56
|
+
the browser, needs no capability guess, and does not break when a profile mounts
|
|
57
|
+
a different picker.
|
|
58
|
+
|
|
59
|
+
The tray of the foot keeps the sign-in state (`GitHub · <login>`, or
|
|
60
|
+
`GitHub · signed out`) next to the balance, so the account is visible without
|
|
61
|
+
opening a dialog; clicking it opens the repository dialog directly.
|
|
44
62
|
|
|
45
63
|
## Not used from the AryaAI-era plugin
|
|
46
64
|
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The folder list behind "On this PC".
|
|
3
|
+
*
|
|
4
|
+
* Why this exists at all: the add-a-workspace flow is driven by the
|
|
5
|
+
* `sidebar.workspaces.directoryFlow` slot, whose single occupant is a directory
|
|
6
|
+
* picker. A picker serves exactly one capability — `browse` (`list`,
|
|
7
|
+
* `createDirectory`) or `native` (`pick`) — and the browser build composes the
|
|
8
|
+
* one without a chooser to show, so `pick` there waits for a dialog that never
|
|
9
|
+
* opens. Owning the listing instead of borrowing it keeps the row behaving the
|
|
10
|
+
* same in the app and in the browser, and keeps the plugin free of a dependency
|
|
11
|
+
* on which picker a profile happens to mount.
|
|
12
|
+
*
|
|
13
|
+
* The route is the same documented shape the GitHub half uses: a registered
|
|
14
|
+
* `connection.fetch` path, answering JSON, with the browser addressing the
|
|
15
|
+
* document-relative form.
|
|
16
|
+
*/
|
|
17
|
+
import { promises as fs } from 'node:fs'
|
|
18
|
+
import { homedir } from 'node:os'
|
|
19
|
+
import { dirname, join, resolve } from 'node:path'
|
|
20
|
+
import { json } from './github.js'
|
|
21
|
+
|
|
22
|
+
/** Absolute path the Host registers; the browser uses {@link FOLDERS_ROUTE}. */
|
|
23
|
+
export const FOLDERS_PATH = '/api/polish.folders'
|
|
24
|
+
|
|
25
|
+
/** Document-relative form the bundle calls with `fetch`. */
|
|
26
|
+
export const FOLDERS_ROUTE = FOLDERS_PATH.slice(1)
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Places a folder browser can start from.
|
|
30
|
+
*
|
|
31
|
+
* @returns labelled roots, the profile's home first.
|
|
32
|
+
*/
|
|
33
|
+
export function folderRoots(home = homedir()) {
|
|
34
|
+
const roots = [{ label: 'Home', path: home }]
|
|
35
|
+
const drive = process.env.SystemDrive ?? 'C:'
|
|
36
|
+
if (drive !== '') roots.push({ label: `${drive}\\`, path: `${drive}\\` })
|
|
37
|
+
return roots
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* List the subdirectories of one directory.
|
|
42
|
+
*
|
|
43
|
+
* @param input - a directory path; the client passes what the previous listing
|
|
44
|
+
* returned, and never a path it composed itself.
|
|
45
|
+
* @returns the resolved path, its parent (null at a drive root) and the child
|
|
46
|
+
* folders sorted by name, hidden entries excluded.
|
|
47
|
+
*/
|
|
48
|
+
export async function listFolders(input) {
|
|
49
|
+
if (typeof input !== 'string' || input === '') {
|
|
50
|
+
throw new Error('a folder path is required')
|
|
51
|
+
}
|
|
52
|
+
const path = resolve(input)
|
|
53
|
+
const entries = []
|
|
54
|
+
for (const entry of await fs.readdir(path, { withFileTypes: true })) {
|
|
55
|
+
if (entry.name.startsWith('.') || entry.name.startsWith('$')) continue
|
|
56
|
+
if (entry.isDirectory() === false) continue
|
|
57
|
+
entries.push({ name: entry.name, path: join(path, entry.name) })
|
|
58
|
+
}
|
|
59
|
+
entries.sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: 'base' }))
|
|
60
|
+
const parent = dirname(path)
|
|
61
|
+
return { path, parent: parent === path ? null : parent, entries }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Answer one request from the browser.
|
|
66
|
+
*
|
|
67
|
+
* @param body - `{ action: 'roots' }` or `{ action: 'ls', path }`.
|
|
68
|
+
* @returns a Response with a JSON body.
|
|
69
|
+
*/
|
|
70
|
+
export async function handleFolders(body) {
|
|
71
|
+
const action = body?.action
|
|
72
|
+
if (action === 'roots') return json({ roots: folderRoots() })
|
|
73
|
+
if (action === 'ls') {
|
|
74
|
+
try {
|
|
75
|
+
return json(await listFolders(body.path))
|
|
76
|
+
} catch (error) {
|
|
77
|
+
return json({ error: error instanceof Error ? error.message : 'cannot list that folder' }, 400)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return json({ error: `unknown action ${String(action)}` }, 400)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The host half mounted by the `workspaceSources` feature. */
|
|
84
|
+
export const foldersFeature = {
|
|
85
|
+
name: 'polish-folders',
|
|
86
|
+
inject: ['connection'],
|
|
87
|
+
apply(ctx) {
|
|
88
|
+
ctx.effect(() => ctx.connection.fetch.register({
|
|
89
|
+
path: FOLDERS_PATH,
|
|
90
|
+
methods: ['POST'],
|
|
91
|
+
requestBody: 'buffered',
|
|
92
|
+
fetch: async request => {
|
|
93
|
+
if (request.method !== 'POST') return json({ error: 'POST required' }, 405)
|
|
94
|
+
let body
|
|
95
|
+
try {
|
|
96
|
+
body = await request.json()
|
|
97
|
+
} catch {
|
|
98
|
+
return json({ error: 'a JSON body is required' }, 400)
|
|
99
|
+
}
|
|
100
|
+
return handleFolders(body)
|
|
101
|
+
},
|
|
102
|
+
}))
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Mount the folder route for one configuration.
|
|
108
|
+
*
|
|
109
|
+
* @param ctx - the row's context.
|
|
110
|
+
* @param config - the row's resolved configuration accessor.
|
|
111
|
+
* @returns the mounted plugin fiber.
|
|
112
|
+
*/
|
|
113
|
+
export function mountFolders(ctx, config) {
|
|
114
|
+
return ctx.plugin({ ...foldersFeature, config })
|
|
115
|
+
}
|
package/entries/registry.js
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import { mountCost } from './cost.js'
|
|
27
|
+
import { mountFolders } from './folders.js'
|
|
27
28
|
import { mountGithub } from './github.js'
|
|
28
29
|
|
|
29
30
|
/**
|
|
@@ -60,6 +61,13 @@ export const features = [
|
|
|
60
61
|
defaultOn: true,
|
|
61
62
|
mount: mountGithub,
|
|
62
63
|
},
|
|
64
|
+
{
|
|
65
|
+
id: 'workspaceSources',
|
|
66
|
+
label: 'Add a workspace from this PC or GitHub',
|
|
67
|
+
description: 'Put an "On this PC" / "GitHub repository" source menu behind the Workspaces + button.',
|
|
68
|
+
defaultOn: true,
|
|
69
|
+
mount: mountFolders,
|
|
70
|
+
},
|
|
63
71
|
]
|
|
64
72
|
|
|
65
73
|
/**
|
package/lib/client.js
CHANGED
|
@@ -31,11 +31,17 @@ window.__ModuleLoader__.load({
|
|
|
31
31
|
"label": "Add a workspace from GitHub",
|
|
32
32
|
"description": "Sign in with the GitHub CLI, browse your repositories, and clone one as a workspace.",
|
|
33
33
|
"defaultOn": true
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"key": "workspaceSources",
|
|
37
|
+
"label": "Add a workspace from this PC or GitHub",
|
|
38
|
+
"description": "Put an On this PC / GitHub repository source menu behind the Workspaces + button.",
|
|
39
|
+
"defaultOn": true
|
|
34
40
|
}
|
|
35
41
|
]
|
|
36
42
|
const ROW_ID = "polish"
|
|
37
43
|
const ROW_KEY = "@aryacode/dsh-aryacode-polish" + '#' + ROW_ID
|
|
38
|
-
const CLIENT_VERSION = "0.
|
|
44
|
+
const CLIENT_VERSION = "0.6.0"
|
|
39
45
|
|
|
40
46
|
/**
|
|
41
47
|
* The two pure decisions behind the balance line: which wallet to show, and how
|
|
@@ -627,6 +633,7 @@ window.__ModuleLoader__.load({
|
|
|
627
633
|
*/
|
|
628
634
|
const BALANCE_RETRY_DELAYS = [1500, 5000, 15000, 30000]
|
|
629
635
|
|
|
636
|
+
|
|
630
637
|
/**
|
|
631
638
|
* The remaining-balance line in the sidebar foot.
|
|
632
639
|
*
|
|
@@ -788,7 +795,12 @@ window.__ModuleLoader__.load({
|
|
|
788
795
|
*/
|
|
789
796
|
const githubStore = (() => {
|
|
790
797
|
let open = false
|
|
798
|
+
// Set when the dialog was opened from the add-workspace flow: the flow owns
|
|
799
|
+
// workspace creation through `onPicked`, so a clone hands it the path rather
|
|
800
|
+
// than calling the Workspace service itself.
|
|
801
|
+
let picker
|
|
791
802
|
const listeners = new Set()
|
|
803
|
+
const publish = () => { for (const listener of listeners) listener() }
|
|
792
804
|
return {
|
|
793
805
|
isOpen: () => open,
|
|
794
806
|
subscribe(listener) {
|
|
@@ -798,7 +810,21 @@ window.__ModuleLoader__.load({
|
|
|
798
810
|
set(next) {
|
|
799
811
|
if (open === next) return
|
|
800
812
|
open = next
|
|
801
|
-
|
|
813
|
+
if (!next) picker = undefined
|
|
814
|
+
publish()
|
|
815
|
+
},
|
|
816
|
+
/** Open the dialog, optionally on behalf of the add-workspace flow. */
|
|
817
|
+
open(onPicked) {
|
|
818
|
+
picker = onPicked
|
|
819
|
+
if (open) return
|
|
820
|
+
open = true
|
|
821
|
+
publish()
|
|
822
|
+
},
|
|
823
|
+
/** Take the flow's `onPicked` once, so a second clone cannot reuse it. */
|
|
824
|
+
takePicker() {
|
|
825
|
+
const current = picker
|
|
826
|
+
picker = undefined
|
|
827
|
+
return current
|
|
802
828
|
},
|
|
803
829
|
}
|
|
804
830
|
})()
|
|
@@ -823,6 +849,28 @@ window.__ModuleLoader__.load({
|
|
|
823
849
|
return payload.value
|
|
824
850
|
}
|
|
825
851
|
|
|
852
|
+
/** The Host route the folder browser talks over. Document-relative, like every app route. */
|
|
853
|
+
const FOLDERS_ROUTE = 'api/polish.folders'
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Post one action to the folder route.
|
|
857
|
+
* @param body - `{ action: 'roots' }` or `{ action: 'ls', path }`.
|
|
858
|
+
* @returns the route's listing, or throws with its own message.
|
|
859
|
+
*/
|
|
860
|
+
async function foldersCall(body) {
|
|
861
|
+
const response = await fetch(FOLDERS_ROUTE, {
|
|
862
|
+
method: 'POST',
|
|
863
|
+
headers: { 'content-type': 'application/json' },
|
|
864
|
+
body: JSON.stringify(body),
|
|
865
|
+
})
|
|
866
|
+
const payload = await response.json().catch(() => undefined)
|
|
867
|
+
if (payload === undefined || typeof payload !== 'object') {
|
|
868
|
+
throw new Error(`the Host answered ${String(response.status)}`)
|
|
869
|
+
}
|
|
870
|
+
if (payload.error !== undefined) throw new Error(String(payload.error))
|
|
871
|
+
return payload
|
|
872
|
+
}
|
|
873
|
+
|
|
826
874
|
/** Modal backdrop and panel skin, matching the Shell's own dialogs. */
|
|
827
875
|
const GITHUB_BACKDROP_STYLE = {
|
|
828
876
|
position: 'fixed',
|
|
@@ -946,7 +994,15 @@ window.__ModuleLoader__.load({
|
|
|
946
994
|
setError('')
|
|
947
995
|
Promise.resolve()
|
|
948
996
|
.then(() => githubCall('clone', { owner: repo.owner, name: repo.name }))
|
|
949
|
-
|
|
997
|
+
// The add-workspace flow owns creation when it opened this dialog; a
|
|
998
|
+
// dialog opened from the footer creates the workspace itself.
|
|
999
|
+
.then(result => {
|
|
1000
|
+
const onPicked = githubStore.takePicker()
|
|
1001
|
+
const land = onPicked === undefined
|
|
1002
|
+
? Promise.resolve(props.createWorkspace(result.path))
|
|
1003
|
+
: Promise.resolve(onPicked(result.path))
|
|
1004
|
+
return land.then(() => result)
|
|
1005
|
+
})
|
|
950
1006
|
.then(result => {
|
|
951
1007
|
setBusy('')
|
|
952
1008
|
githubStore.set(false)
|
|
@@ -1023,59 +1079,309 @@ window.__ModuleLoader__.load({
|
|
|
1023
1079
|
, document.body)
|
|
1024
1080
|
}
|
|
1025
1081
|
|
|
1082
|
+
/** The compact footer-row skin shared by the status line. */
|
|
1083
|
+
const FOOT_ROW_STYLE = {
|
|
1084
|
+
display: 'flex',
|
|
1085
|
+
alignItems: 'center',
|
|
1086
|
+
gap: '6px',
|
|
1087
|
+
maxWidth: '100%',
|
|
1088
|
+
minWidth: 0,
|
|
1089
|
+
padding: '2px 6px',
|
|
1090
|
+
border: 'none',
|
|
1091
|
+
borderRadius: 'var(--dsw-radius-sm, 6px)',
|
|
1092
|
+
background: 'transparent',
|
|
1093
|
+
color: 'var(--dsw-alias-label-tertiary, inherit)',
|
|
1094
|
+
font: 'inherit',
|
|
1095
|
+
fontSize: '12px',
|
|
1096
|
+
lineHeight: '18px',
|
|
1097
|
+
fontVariantNumeric: 'tabular-nums',
|
|
1098
|
+
whiteSpace: 'nowrap',
|
|
1099
|
+
overflow: 'hidden',
|
|
1100
|
+
textOverflow: 'ellipsis',
|
|
1101
|
+
cursor: 'pointer',
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1026
1104
|
/**
|
|
1027
|
-
* The footer
|
|
1105
|
+
* The footer status line: which GitHub session the "+" menu will clone with.
|
|
1106
|
+
*
|
|
1107
|
+
* It reads the same `status` action the dialog does, so it costs one request
|
|
1108
|
+
* when the Shell loads and one whenever the window regains focus.
|
|
1109
|
+
*
|
|
1028
1110
|
* @param props - our lazy `configForms`.
|
|
1029
1111
|
* @returns the row, or null when the feature is switched off.
|
|
1030
1112
|
*/
|
|
1031
|
-
function
|
|
1113
|
+
function GithubStatus(props) {
|
|
1032
1114
|
const enabled = useSwitch(props.configForms, 'githubWorkspace', true)
|
|
1115
|
+
const [status, setStatus] = React.useState(undefined)
|
|
1116
|
+
React.useEffect(() => {
|
|
1117
|
+
if (!enabled) return undefined
|
|
1118
|
+
let live = true
|
|
1119
|
+
const read = () => {
|
|
1120
|
+
Promise.resolve()
|
|
1121
|
+
.then(() => githubCall('status'))
|
|
1122
|
+
.then(next => { if (live) setStatus(next) })
|
|
1123
|
+
.catch(() => { if (live) setStatus({ signedIn: false }) })
|
|
1124
|
+
}
|
|
1125
|
+
read()
|
|
1126
|
+
const onFocus = () => { read() }
|
|
1127
|
+
window.addEventListener('focus', onFocus)
|
|
1128
|
+
return () => {
|
|
1129
|
+
live = false
|
|
1130
|
+
window.removeEventListener('focus', onFocus)
|
|
1131
|
+
}
|
|
1132
|
+
}, [enabled])
|
|
1033
1133
|
if (!enabled) return null
|
|
1034
|
-
|
|
1134
|
+
const signedIn = status !== undefined && status.signedIn === true
|
|
1135
|
+
const label = status === undefined
|
|
1136
|
+
? 'GitHub · checking…'
|
|
1137
|
+
: signedIn ? `GitHub · ${String(status.login)}` : 'GitHub · signed out'
|
|
1138
|
+
return React.createElement('div', { style: { padding: '2px 8px 0', minWidth: 0 } },
|
|
1035
1139
|
React.createElement('button', {
|
|
1036
1140
|
type: 'button',
|
|
1037
|
-
'data-polish-github': '
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1141
|
+
'data-polish-github': 'status',
|
|
1142
|
+
'data-polish-github-state': signedIn ? 'in' : status === undefined ? 'checking' : 'out',
|
|
1143
|
+
title: signedIn
|
|
1144
|
+
? 'Add a workspace from GitHub'
|
|
1145
|
+
: 'Sign in with the GitHub CLI (gh auth login), then add a repository as a workspace',
|
|
1146
|
+
'aria-label': label,
|
|
1147
|
+
onClick: () => githubStore.open(),
|
|
1148
|
+
style: FOOT_ROW_STYLE,
|
|
1149
|
+
onMouseEnter: event => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
|
|
1150
|
+
onMouseLeave: event => { event.currentTarget.style.background = 'transparent' },
|
|
1151
|
+
}, label))
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/** One row of the add-workspace source menu. */
|
|
1155
|
+
const SOURCE_ROW_STYLE = {
|
|
1156
|
+
display: 'flex',
|
|
1157
|
+
flexDirection: 'column',
|
|
1158
|
+
alignItems: 'flex-start',
|
|
1159
|
+
gap: '2px',
|
|
1160
|
+
width: '100%',
|
|
1161
|
+
padding: '8px 10px',
|
|
1162
|
+
border: 'none',
|
|
1163
|
+
borderRadius: 'var(--dsw-radius-sm, 6px)',
|
|
1164
|
+
background: 'transparent',
|
|
1165
|
+
color: 'inherit',
|
|
1166
|
+
font: 'inherit',
|
|
1167
|
+
textAlign: 'left',
|
|
1168
|
+
cursor: 'pointer',
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
/**
|
|
1172
|
+
* The "+" add-workspace flow: choose where the workspace comes from.
|
|
1173
|
+
*
|
|
1174
|
+
* The Harness shows its own `Add workspace…` row only while this slot is
|
|
1175
|
+
* occupied, and a single-owner slot means this dialog stands in for the folder
|
|
1176
|
+
* picker. It therefore offers both sources: the machine (the Host's own OS
|
|
1177
|
+
* chooser when the composition serves the `native` capability, otherwise a
|
|
1178
|
+
* folder list driven by the browse primitives) and GitHub.
|
|
1179
|
+
*
|
|
1180
|
+
* @param props - the slot owner's `open`/`busy`/`onPicked`/`onCancel`/`onError`,
|
|
1181
|
+
* our lazy `configForms`, and the directory-picker calls.
|
|
1182
|
+
* @returns the flow, or null while it is closed or switched off.
|
|
1183
|
+
*/
|
|
1184
|
+
function WorkspaceSourceFlow(props) {
|
|
1185
|
+
const enabled = useSwitch(props.configForms, 'workspaceSources', true)
|
|
1186
|
+
const [step, setStep] = React.useState('menu')
|
|
1187
|
+
const [listing, setListing] = React.useState(undefined)
|
|
1188
|
+
const [error, setError] = React.useState('')
|
|
1189
|
+
const [busy, setBusy] = React.useState(false)
|
|
1190
|
+
|
|
1191
|
+
const cancel = () => {
|
|
1192
|
+
if (typeof props.onCancel === 'function') props.onCancel()
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// Escape closes the flow. Declared with the other hooks, before the early
|
|
1196
|
+
// return, so the hook order stays fixed while the flow opens and closes.
|
|
1197
|
+
React.useEffect(() => {
|
|
1198
|
+
if (!enabled || props.open !== true) return undefined
|
|
1199
|
+
const onKey = event => { if (event.key === 'Escape') cancel() }
|
|
1200
|
+
document.addEventListener('keydown', onKey)
|
|
1201
|
+
return () => document.removeEventListener('keydown', onKey)
|
|
1202
|
+
}, [enabled, props.open])
|
|
1203
|
+
|
|
1204
|
+
if (!enabled || props.open !== true) return null
|
|
1205
|
+
|
|
1206
|
+
const fail = message => {
|
|
1207
|
+
setBusy(false)
|
|
1208
|
+
setError(message)
|
|
1209
|
+
}
|
|
1210
|
+
const messageOf = reason => (reason instanceof Error ? reason.message : String(reason))
|
|
1211
|
+
|
|
1212
|
+
const land = path => {
|
|
1213
|
+
setBusy(true)
|
|
1214
|
+
setError('')
|
|
1215
|
+
Promise.resolve()
|
|
1216
|
+
.then(() => props.onPicked(path))
|
|
1217
|
+
.catch(reason => fail(messageOf(reason)))
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
const browse = path => {
|
|
1221
|
+
setBusy(true)
|
|
1222
|
+
setError('')
|
|
1223
|
+
Promise.resolve()
|
|
1224
|
+
.then(() => foldersCall({ action: 'ls', path }))
|
|
1225
|
+
.then(level => {
|
|
1226
|
+
setListing(level)
|
|
1227
|
+
setBusy(false)
|
|
1228
|
+
setStep('browse')
|
|
1229
|
+
})
|
|
1230
|
+
.catch(reason => fail(messageOf(reason)))
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/**
|
|
1234
|
+
* Start the folder browser at the machine's roots.
|
|
1235
|
+
*
|
|
1236
|
+
* This deliberately does not call the Host's directory picker: a composed
|
|
1237
|
+
* picker serves either `browse` or `native`, never both, and the browser build
|
|
1238
|
+
* composes the one whose chooser cannot open there — a `pick` call would wait
|
|
1239
|
+
* for a dialog that never appears. Our own listing behaves the same in the app
|
|
1240
|
+
* and in the browser, and hands the chosen path back through `onPicked`.
|
|
1241
|
+
*/
|
|
1242
|
+
const chooseOnPc = () => {
|
|
1243
|
+
if (busy) return
|
|
1244
|
+
setBusy(true)
|
|
1245
|
+
setError('')
|
|
1246
|
+
Promise.resolve()
|
|
1247
|
+
.then(() => foldersCall({ action: 'roots' }))
|
|
1248
|
+
.then(payload => {
|
|
1249
|
+
const roots = Array.isArray(payload.roots) ? payload.roots : []
|
|
1250
|
+
setListing({
|
|
1251
|
+
path: '',
|
|
1252
|
+
parent: null,
|
|
1253
|
+
entries: roots.map(root => ({ name: root.label, path: root.path })),
|
|
1254
|
+
})
|
|
1255
|
+
setBusy(false)
|
|
1256
|
+
setStep('browse')
|
|
1257
|
+
})
|
|
1258
|
+
.catch(reason => fail(messageOf(reason)))
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
const openGithub = () => {
|
|
1262
|
+
githubStore.open(path => props.onPicked(path))
|
|
1263
|
+
cancel()
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
const rows = step === 'menu'
|
|
1267
|
+
? [
|
|
1268
|
+
React.createElement('button', {
|
|
1269
|
+
key: 'pc',
|
|
1270
|
+
type: 'button',
|
|
1271
|
+
'data-polish-source': 'machine',
|
|
1272
|
+
disabled: busy,
|
|
1273
|
+
onClick: chooseOnPc,
|
|
1274
|
+
style: SOURCE_ROW_STYLE,
|
|
1275
|
+
onMouseEnter: event => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
|
|
1276
|
+
onMouseLeave: event => { event.currentTarget.style.background = 'transparent' },
|
|
1277
|
+
},
|
|
1278
|
+
React.createElement('span', { style: { fontWeight: 500 } }, 'On this PC'),
|
|
1279
|
+
React.createElement('span', { style: { opacity: 0.7 } }, 'Choose a folder on this machine')),
|
|
1280
|
+
React.createElement('button', {
|
|
1281
|
+
key: 'github',
|
|
1282
|
+
type: 'button',
|
|
1283
|
+
'data-polish-source': 'github',
|
|
1284
|
+
disabled: busy,
|
|
1285
|
+
onClick: openGithub,
|
|
1286
|
+
style: SOURCE_ROW_STYLE,
|
|
1287
|
+
onMouseEnter: event => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
|
|
1288
|
+
onMouseLeave: event => { event.currentTarget.style.background = 'transparent' },
|
|
1289
|
+
},
|
|
1290
|
+
React.createElement('span', { style: { fontWeight: 500 } }, 'GitHub repository…'),
|
|
1291
|
+
React.createElement('span', { style: { opacity: 0.7 } }, 'Clone one of your repositories as a workspace')),
|
|
1292
|
+
]
|
|
1293
|
+
: [
|
|
1294
|
+
React.createElement('div', {
|
|
1295
|
+
key: 'head',
|
|
1296
|
+
style: { display: 'flex', alignItems: 'center', gap: '8px', padding: '2px 4px 6px', minWidth: 0 },
|
|
1297
|
+
},
|
|
1298
|
+
React.createElement('span', {
|
|
1299
|
+
style: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
|
|
1300
|
+
title: listing !== undefined ? listing.path : '',
|
|
1301
|
+
}, listing === undefined || listing.path === '' ? 'This PC' : listing.path),
|
|
1302
|
+
listing === undefined || listing.path === ''
|
|
1303
|
+
? null
|
|
1304
|
+
: React.createElement('button', {
|
|
1305
|
+
type: 'button',
|
|
1306
|
+
'data-polish-pick': 'here',
|
|
1307
|
+
disabled: busy,
|
|
1308
|
+
onClick: () => { if (listing !== undefined) land(listing.path) },
|
|
1309
|
+
style: { ...FOOT_ROW_STYLE, color: 'var(--dsw-alias-label-primary, inherit)', background: 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
|
|
1310
|
+
}, 'Use this folder')),
|
|
1311
|
+
...(listing === undefined || listing.parent === null || listing.parent === undefined
|
|
1312
|
+
? []
|
|
1313
|
+
: [React.createElement('button', {
|
|
1314
|
+
key: 'up',
|
|
1315
|
+
type: 'button',
|
|
1316
|
+
'data-polish-folder': '..',
|
|
1317
|
+
disabled: busy,
|
|
1318
|
+
onClick: () => browse(listing.parent),
|
|
1319
|
+
style: SOURCE_ROW_STYLE,
|
|
1320
|
+
onMouseEnter: event => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
|
|
1321
|
+
onMouseLeave: event => { event.currentTarget.style.background = 'transparent' },
|
|
1322
|
+
}, React.createElement('span', { title: listing.parent }, '..'))]),
|
|
1323
|
+
...(listing === undefined
|
|
1324
|
+
? []
|
|
1325
|
+
: listing.entries.map(entry => React.createElement('button', {
|
|
1326
|
+
key: entry.path,
|
|
1327
|
+
type: 'button',
|
|
1328
|
+
'data-polish-folder': entry.name,
|
|
1329
|
+
disabled: busy,
|
|
1330
|
+
onClick: () => browse(entry.path),
|
|
1331
|
+
style: SOURCE_ROW_STYLE,
|
|
1332
|
+
onMouseEnter: event => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
|
|
1333
|
+
onMouseLeave: event => { event.currentTarget.style.background = 'transparent' },
|
|
1334
|
+
}, React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title: entry.path }, entry.name)))),
|
|
1335
|
+
]
|
|
1336
|
+
|
|
1337
|
+
// The flow is rendered inside the Shell's add-workspace popover, which is
|
|
1338
|
+
// narrow and sits under the sidebar's own stacking order. A portal to the body
|
|
1339
|
+
// puts it above everything and keeps the rows readable at their own width.
|
|
1340
|
+
return ReactDOM.createPortal(
|
|
1341
|
+
React.createElement('div', {
|
|
1342
|
+
style: GITHUB_BACKDROP_STYLE,
|
|
1343
|
+
'data-polish-flow-backdrop': step,
|
|
1344
|
+
onPointerDown: event => { if (event.target === event.currentTarget) cancel() },
|
|
1345
|
+
},
|
|
1346
|
+
React.createElement('div', {
|
|
1347
|
+
'data-polish-flow': step,
|
|
1348
|
+
role: 'dialog',
|
|
1349
|
+
'aria-label': 'Add a workspace',
|
|
1041
1350
|
style: {
|
|
1042
1351
|
display: 'flex',
|
|
1043
|
-
|
|
1044
|
-
gap: '
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
padding: '
|
|
1048
|
-
|
|
1049
|
-
borderRadius: 'var(--dsw-radius-
|
|
1050
|
-
background: '
|
|
1051
|
-
|
|
1052
|
-
font: 'inherit',
|
|
1352
|
+
flexDirection: 'column',
|
|
1353
|
+
gap: '2px',
|
|
1354
|
+
width: 'min(360px, calc(100vw - 32px))',
|
|
1355
|
+
maxHeight: 'min(420px, calc(100vh - 96px))',
|
|
1356
|
+
padding: '6px',
|
|
1357
|
+
overflowY: 'auto',
|
|
1358
|
+
borderRadius: 'var(--dsw-radius-lg, 12px)',
|
|
1359
|
+
background: 'var(--dsw-specific-menu, Canvas)',
|
|
1360
|
+
boxShadow: 'var(--dsw-elevation-prominent, 0 8px 24px rgba(0,0,0,0.18))',
|
|
1053
1361
|
fontSize: '12px',
|
|
1054
1362
|
lineHeight: '18px',
|
|
1055
|
-
|
|
1056
|
-
whiteSpace: 'nowrap',
|
|
1057
|
-
overflow: 'hidden',
|
|
1058
|
-
textOverflow: 'ellipsis',
|
|
1059
|
-
cursor: 'pointer',
|
|
1363
|
+
color: 'var(--dsw-alias-label-secondary, inherit)',
|
|
1060
1364
|
},
|
|
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
1365
|
},
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
React.createElement('
|
|
1077
|
-
|
|
1078
|
-
|
|
1366
|
+
error !== ''
|
|
1367
|
+
? React.createElement('div', { style: { padding: '4px 6px', color: 'var(--dsw-alias-label-error, #b42318)' } }, error)
|
|
1368
|
+
: null,
|
|
1369
|
+
...rows,
|
|
1370
|
+
step === 'menu'
|
|
1371
|
+
? null
|
|
1372
|
+
: React.createElement('button', {
|
|
1373
|
+
type: 'button',
|
|
1374
|
+
'data-polish-pick': 'back',
|
|
1375
|
+
onClick: () => { setStep('menu'); setListing(undefined); setError('') },
|
|
1376
|
+
style: { ...FOOT_ROW_STYLE, justifyContent: 'flex-start' },
|
|
1377
|
+
}, 'Back'),
|
|
1378
|
+
React.createElement('button', {
|
|
1379
|
+
type: 'button',
|
|
1380
|
+
'data-polish-pick': 'cancel',
|
|
1381
|
+
onClick: cancel,
|
|
1382
|
+
style: { ...FOOT_ROW_STYLE, justifyContent: 'flex-start' },
|
|
1383
|
+
}, 'Cancel'))),
|
|
1384
|
+
document.body)
|
|
1079
1385
|
}
|
|
1080
1386
|
|
|
1081
1387
|
return {
|
|
@@ -1156,13 +1462,30 @@ window.__ModuleLoader__.load({
|
|
|
1156
1462
|
id: 'polish-github',
|
|
1157
1463
|
inject: () => ({ configForms, createWorkspace }),
|
|
1158
1464
|
}, GitHubPanel))
|
|
1465
|
+
// The footer keeps the sign-in state, not an add button: adding a
|
|
1466
|
+
// workspace belongs in the "+" menu, where the Shell says it does.
|
|
1159
1467
|
githubCtx.slots.inject('sidebar.footer.action', () => githubCtx.slots.register({
|
|
1160
1468
|
name: 'sidebar.footer.action',
|
|
1161
|
-
id: 'polish-github-
|
|
1469
|
+
id: 'polish-github-status',
|
|
1162
1470
|
order: 4,
|
|
1163
1471
|
inject: () => ({ configForms }),
|
|
1164
|
-
},
|
|
1472
|
+
}, GithubStatus))
|
|
1165
1473
|
})
|
|
1474
|
+
// The "+" menu's `Add workspace…` row exists only while a plugin occupies
|
|
1475
|
+
// this single-owner hole, so occupying it means standing in for the folder
|
|
1476
|
+
// picker: the flow offers the machine and GitHub as sources. It needs no
|
|
1477
|
+
// Remote injection: the folder list and the clone both come from this
|
|
1478
|
+
// bundle's own Host routes, which is what makes the flow behave the same in
|
|
1479
|
+
// the browser and in the app.
|
|
1480
|
+
ctx.slots.inject('sidebar.workspaces.directoryFlow', () => ctx.slots.register({
|
|
1481
|
+
name: 'sidebar.workspaces.directoryFlow',
|
|
1482
|
+
// A single slot clashes only at the exact priority; another priority
|
|
1483
|
+
// shadows it and the LOWEST priority renders. The shipped pickers
|
|
1484
|
+
// register at 0, so -1 is what puts this flow in front of them without
|
|
1485
|
+
// making their own activation throw.
|
|
1486
|
+
priority: -1,
|
|
1487
|
+
inject: () => ({ configForms }),
|
|
1488
|
+
}, WorkspaceSourceFlow))
|
|
1166
1489
|
},
|
|
1167
1490
|
}
|
|
1168
1491
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aryacode/dsh-aryacode-polish",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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/github.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/folders.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"
|