@aryacode/dsh-aryacode-polish 0.6.2 → 0.8.1
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 +52 -0
- package/README.md +2 -1
- package/client/face.js +615 -26
- package/client/switches.json +6 -0
- package/docs/github-workspaces.md +24 -0
- package/docs/panel-screenshot.md +98 -0
- package/entries/github.js +218 -3
- package/entries/registry.js +6 -0
- package/lib/client.js +645 -27
- package/package.json +4 -2
- package/vendor/html2canvas.min.js +20 -0
package/client/face.js
CHANGED
|
@@ -678,6 +678,29 @@ const githubStore = (() => {
|
|
|
678
678
|
}
|
|
679
679
|
})()
|
|
680
680
|
|
|
681
|
+
/**
|
|
682
|
+
* The last GitHub status anyone read, shared by the dialog and the footer line.
|
|
683
|
+
*
|
|
684
|
+
* The footer used to re-read only on window focus, so signing out in the dialog
|
|
685
|
+
* left `GitHub · KonnectSuite` on screen while the CLI was already signed out (or
|
|
686
|
+
* signed in as another account). One store, written by every read, keeps them equal.
|
|
687
|
+
*/
|
|
688
|
+
const githubStatusStore = (() => {
|
|
689
|
+
let value
|
|
690
|
+
const listeners = new Set()
|
|
691
|
+
return {
|
|
692
|
+
get: () => value,
|
|
693
|
+
subscribe(listener) {
|
|
694
|
+
listeners.add(listener)
|
|
695
|
+
return () => { listeners.delete(listener) }
|
|
696
|
+
},
|
|
697
|
+
set(next) {
|
|
698
|
+
value = next
|
|
699
|
+
for (const listener of listeners) listener()
|
|
700
|
+
},
|
|
701
|
+
}
|
|
702
|
+
})()
|
|
703
|
+
|
|
681
704
|
/**
|
|
682
705
|
* Post one action to the GitHub route.
|
|
683
706
|
* @param action - `status`, `repos` or `clone`.
|
|
@@ -747,6 +770,20 @@ const GITHUB_LIST_STYLE = {
|
|
|
747
770
|
borderTop: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.12))',
|
|
748
771
|
}
|
|
749
772
|
|
|
773
|
+
/** A secondary button in the sign-in block, sized like the harness's own. */
|
|
774
|
+
const GITHUB_BUTTON_STYLE = {
|
|
775
|
+
display: 'inline-flex',
|
|
776
|
+
alignItems: 'center',
|
|
777
|
+
justifyContent: 'center',
|
|
778
|
+
padding: '4px 10px',
|
|
779
|
+
border: '0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.16))',
|
|
780
|
+
borderRadius: 'var(--dsw-radius-sm, 6px)',
|
|
781
|
+
background: 'transparent',
|
|
782
|
+
color: 'inherit',
|
|
783
|
+
font: 'inherit',
|
|
784
|
+
cursor: 'pointer',
|
|
785
|
+
}
|
|
786
|
+
|
|
750
787
|
const GITHUB_ROW_STYLE = {
|
|
751
788
|
display: 'flex',
|
|
752
789
|
alignItems: 'center',
|
|
@@ -762,8 +799,7 @@ const GITHUB_ROW_STYLE = {
|
|
|
762
799
|
borderBottom: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.08))',
|
|
763
800
|
}
|
|
764
801
|
|
|
765
|
-
const GITHUB_INPUT_STYLE = {
|
|
766
|
-
width: '100%',
|
|
802
|
+
const GITHUB_INPUT_STYLE = { width: '100%',
|
|
767
803
|
boxSizing: 'border-box',
|
|
768
804
|
padding: '6px 8px',
|
|
769
805
|
font: 'inherit',
|
|
@@ -793,6 +829,33 @@ function GitHubPanel(props) {
|
|
|
793
829
|
const [query, setQuery] = React.useState('')
|
|
794
830
|
const [error, setError] = React.useState('')
|
|
795
831
|
const [busy, setBusy] = React.useState('')
|
|
832
|
+
const [signIn, setSignIn] = React.useState({ state: 'idle', code: '', url: '', reason: '' })
|
|
833
|
+
const [confirmOut, setConfirmOut] = React.useState(false)
|
|
834
|
+
const [confirmAll, setConfirmAll] = React.useState(false)
|
|
835
|
+
const [notice, setNotice] = React.useState('')
|
|
836
|
+
const [copied, setCopied] = React.useState(false)
|
|
837
|
+
|
|
838
|
+
/** Put the one-time code on the clipboard, so it can be pasted in the browser. */
|
|
839
|
+
const copyCode = () => {
|
|
840
|
+
const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard
|
|
841
|
+
if (clipboard === undefined || typeof clipboard.writeText !== 'function') return
|
|
842
|
+
Promise.resolve(clipboard.writeText(signIn.code))
|
|
843
|
+
.then(() => {
|
|
844
|
+
setCopied(true)
|
|
845
|
+
setTimeout(() => setCopied(false), 2000)
|
|
846
|
+
})
|
|
847
|
+
.catch(() => setCopied(false))
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/** Re-read the session, and the repository list when it is signed in. */
|
|
851
|
+
const refresh = () => Promise.resolve()
|
|
852
|
+
.then(() => githubCall('status'))
|
|
853
|
+
.then(status => {
|
|
854
|
+
setAccount(status)
|
|
855
|
+
githubStatusStore.set(status)
|
|
856
|
+
if (status.signedIn !== true) return undefined
|
|
857
|
+
return githubCall('repos').then(list => setRepos(Array.isArray(list.repos) ? list.repos : []))
|
|
858
|
+
})
|
|
796
859
|
|
|
797
860
|
React.useEffect(() => {
|
|
798
861
|
if (!open || !enabled) return undefined
|
|
@@ -801,17 +864,9 @@ function GitHubPanel(props) {
|
|
|
801
864
|
setError('')
|
|
802
865
|
setBusy('')
|
|
803
866
|
setQuery('')
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
if (!live) return undefined
|
|
808
|
-
setAccount(status)
|
|
809
|
-
if (status.signedIn !== true) return undefined
|
|
810
|
-
return githubCall('repos').then(list => {
|
|
811
|
-
if (!live) return
|
|
812
|
-
setRepos(Array.isArray(list.repos) ? list.repos : [])
|
|
813
|
-
})
|
|
814
|
-
})
|
|
867
|
+
setConfirmOut(false)
|
|
868
|
+
setSignIn({ state: 'idle', code: '', url: '', reason: '' })
|
|
869
|
+
refresh()
|
|
815
870
|
.then(() => { if (live) setPhase('ready') })
|
|
816
871
|
.catch(reason => {
|
|
817
872
|
if (!live) return
|
|
@@ -821,6 +876,29 @@ function GitHubPanel(props) {
|
|
|
821
876
|
return () => { live = false }
|
|
822
877
|
}, [open, enabled])
|
|
823
878
|
|
|
879
|
+
// The device flow is a long poll: ask the Host what the CLI child reports.
|
|
880
|
+
// `starting` has to be polled too — the Host answers `starting` the moment the
|
|
881
|
+
// child is spawned and only reports the code a tick later, so polling on
|
|
882
|
+
// `pending` alone left the dialog asking for a code that had already arrived.
|
|
883
|
+
React.useEffect(() => {
|
|
884
|
+
if (!open || (signIn.state !== 'pending' && signIn.state !== 'starting')) return undefined
|
|
885
|
+
const timer = setInterval(() => {
|
|
886
|
+
Promise.resolve()
|
|
887
|
+
.then(() => githubCall('loginStatus'))
|
|
888
|
+
.then(answer => {
|
|
889
|
+
setSignIn({ state: answer.state, code: answer.code ?? '', url: answer.url ?? '', reason: answer.reason ?? '' })
|
|
890
|
+
if (answer.state === 'succeeded') {
|
|
891
|
+
setPhase('loading')
|
|
892
|
+
return refresh().then(() => setPhase('ready'))
|
|
893
|
+
}
|
|
894
|
+
if (answer.state === 'failed' || answer.state === 'cancelled') setError(answer.reason ?? 'Sign-in did not finish.')
|
|
895
|
+
return undefined
|
|
896
|
+
})
|
|
897
|
+
.catch(reason => setError(reason instanceof Error ? reason.message : String(reason)))
|
|
898
|
+
}, 2000)
|
|
899
|
+
return () => clearInterval(timer)
|
|
900
|
+
}, [open, signIn.state])
|
|
901
|
+
|
|
824
902
|
React.useEffect(() => {
|
|
825
903
|
if (!open) return undefined
|
|
826
904
|
const onKey = event => { if (event.key === 'Escape') githubStore.set(false) }
|
|
@@ -865,6 +943,78 @@ function GitHubPanel(props) {
|
|
|
865
943
|
})
|
|
866
944
|
}
|
|
867
945
|
|
|
946
|
+
/**
|
|
947
|
+
* Start the CLI's device flow. The Host runs it, so this dialog only renders
|
|
948
|
+
* the code the CLI printed and the page it wants opened.
|
|
949
|
+
*/
|
|
950
|
+
const beginSignIn = () => {
|
|
951
|
+
setError('')
|
|
952
|
+
setSignIn({ state: 'starting', code: '', url: '', reason: '' })
|
|
953
|
+
Promise.resolve()
|
|
954
|
+
.then(() => githubCall('login'))
|
|
955
|
+
.then(answer => setSignIn({
|
|
956
|
+
state: answer.state ?? 'failed',
|
|
957
|
+
code: answer.code ?? '',
|
|
958
|
+
url: answer.url ?? '',
|
|
959
|
+
reason: answer.reason ?? '',
|
|
960
|
+
}))
|
|
961
|
+
.catch(reason => {
|
|
962
|
+
setSignIn({ state: 'failed', code: '', url: '', reason: '' })
|
|
963
|
+
setError(reason instanceof Error ? reason.message : String(reason))
|
|
964
|
+
})
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const cancelSignIn = () => {
|
|
968
|
+
Promise.resolve()
|
|
969
|
+
.then(() => githubCall('loginCancel'))
|
|
970
|
+
.then(() => setSignIn({ state: 'idle', code: '', url: '', reason: '' }))
|
|
971
|
+
.catch(() => setSignIn({ state: 'idle', code: '', url: '', reason: '' }))
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/** Sign the CLI out, which is what every read in this feature depends on. */
|
|
975
|
+
const signOutNow = () => {
|
|
976
|
+
setConfirmOut(false)
|
|
977
|
+
setBusy('sign-out')
|
|
978
|
+
setError('')
|
|
979
|
+
setNotice('')
|
|
980
|
+
Promise.resolve()
|
|
981
|
+
.then(() => githubCall('logout'))
|
|
982
|
+
.then(answer => {
|
|
983
|
+
const remaining = typeof answer.remaining === 'string' ? answer.remaining : ''
|
|
984
|
+
setAccount(undefined)
|
|
985
|
+
setRepos([])
|
|
986
|
+
setSignIn({ state: 'idle', code: '', url: '', reason: '' })
|
|
987
|
+
setNotice(remaining === ''
|
|
988
|
+
? `Signed out of ${String(answer.login)}.`
|
|
989
|
+
: `Signed out of ${String(answer.login)}. The GitHub CLI is now signed in as ${remaining}.`)
|
|
990
|
+
return refresh()
|
|
991
|
+
})
|
|
992
|
+
.catch(reason => setError(reason instanceof Error ? reason.message : String(reason)))
|
|
993
|
+
.then(() => setBusy(''))
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/** Sign every account in the CLI's keyring out, so no silent fallback is left. */
|
|
997
|
+
const signOutAllNow = () => {
|
|
998
|
+
setConfirmAll(false)
|
|
999
|
+
setBusy('sign-out-all')
|
|
1000
|
+
setError('')
|
|
1001
|
+
setNotice('')
|
|
1002
|
+
Promise.resolve()
|
|
1003
|
+
.then(() => githubCall('logoutAll'))
|
|
1004
|
+
.then(answer => {
|
|
1005
|
+
const logins = Array.isArray(answer.logins) ? answer.logins.map(String) : []
|
|
1006
|
+
setAccount(undefined)
|
|
1007
|
+
setRepos([])
|
|
1008
|
+
setSignIn({ state: 'idle', code: '', url: '', reason: '' })
|
|
1009
|
+
setNotice(logins.length === 0
|
|
1010
|
+
? 'No GitHub account was signed in.'
|
|
1011
|
+
: `Signed out of ${logins.join(', ')}.`)
|
|
1012
|
+
return refresh()
|
|
1013
|
+
})
|
|
1014
|
+
.catch(reason => setError(reason instanceof Error ? reason.message : String(reason)))
|
|
1015
|
+
.then(() => setBusy(''))
|
|
1016
|
+
}
|
|
1017
|
+
|
|
868
1018
|
const rows = visible.slice(0, 200).map(repo => React.createElement('button', {
|
|
869
1019
|
key: repo.fullName,
|
|
870
1020
|
type: 'button',
|
|
@@ -892,18 +1042,45 @@ function GitHubPanel(props) {
|
|
|
892
1042
|
React.createElement('div', { style: GITHUB_CARD_STYLE, role: 'dialog', 'aria-label': 'Add a workspace from GitHub' },
|
|
893
1043
|
React.createElement('div', { style: { ...PANEL_TITLE_STYLE, marginBottom: '4px' } },
|
|
894
1044
|
React.createElement('span', null, 'Add a workspace from GitHub'),
|
|
895
|
-
React.createElement('
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
1045
|
+
React.createElement('span', { style: { display: 'flex', alignItems: 'center', gap: '10px' } },
|
|
1046
|
+
signedIn && (Array.isArray(account?.accounts) ? account.accounts.length : 0) > 1
|
|
1047
|
+
? React.createElement('button', {
|
|
1048
|
+
type: 'button',
|
|
1049
|
+
'data-polish-github': 'signoutall',
|
|
1050
|
+
disabled: busy === 'sign-out-all',
|
|
1051
|
+
onClick: () => { if (confirmAll) signOutAllNow(); else setConfirmAll(true) },
|
|
1052
|
+
title: 'Sign every GitHub account in the CLI out of this machine',
|
|
1053
|
+
style: { border: 'none', background: 'transparent', color: 'inherit', font: 'inherit', cursor: 'pointer', opacity: 0.7, padding: 0 },
|
|
1054
|
+
}, busy === 'sign-out-all' ? 'Signing out…' : confirmAll ? 'Sign out all now?' : 'Sign out all')
|
|
1055
|
+
: null,
|
|
1056
|
+
signedIn
|
|
1057
|
+
? React.createElement('button', {
|
|
1058
|
+
type: 'button',
|
|
1059
|
+
'data-polish-github': 'signout',
|
|
1060
|
+
disabled: busy === 'sign-out',
|
|
1061
|
+
onClick: () => { if (confirmOut) signOutNow(); else setConfirmOut(true) },
|
|
1062
|
+
title: 'Sign the GitHub CLI out of this machine',
|
|
1063
|
+
style: { border: 'none', background: 'transparent', color: 'inherit', font: 'inherit', cursor: 'pointer', opacity: 0.7, padding: 0 },
|
|
1064
|
+
}, busy === 'sign-out' ? 'Signing out…' : confirmOut ? 'Sign out now?' : 'Sign out')
|
|
1065
|
+
: null,
|
|
1066
|
+
React.createElement('button', {
|
|
1067
|
+
type: 'button',
|
|
1068
|
+
onClick: () => githubStore.set(false),
|
|
1069
|
+
style: { border: 'none', background: 'transparent', color: 'inherit', font: 'inherit', cursor: 'pointer', opacity: 0.7 },
|
|
1070
|
+
'aria-label': 'Close',
|
|
1071
|
+
}, '×'))),
|
|
901
1072
|
React.createElement('div', { style: { opacity: 0.75, minHeight: '18px' } },
|
|
902
1073
|
phase === 'loading' && !signedIn ? 'Checking the GitHub CLI session…' : null,
|
|
903
1074
|
signedIn ? `Signed in as ${String(account.login)} · cloning into ${root}` : null,
|
|
904
1075
|
!signedIn && phase !== 'loading'
|
|
905
|
-
? '
|
|
1076
|
+
? 'GitHub is not signed in on this machine. The sign-in below is the GitHub CLI device flow, so no token is ever typed here or stored by this plugin.'
|
|
906
1077
|
: null),
|
|
1078
|
+
notice === ''
|
|
1079
|
+
? null
|
|
1080
|
+
: React.createElement('div', {
|
|
1081
|
+
'data-polish-github': 'notice',
|
|
1082
|
+
style: { marginTop: '6px', opacity: 0.85 },
|
|
1083
|
+
}, notice),
|
|
907
1084
|
signedIn
|
|
908
1085
|
? React.createElement('input', {
|
|
909
1086
|
style: { ...GITHUB_INPUT_STYLE, marginTop: '10px' },
|
|
@@ -917,6 +1094,48 @@ function GitHubPanel(props) {
|
|
|
917
1094
|
error !== ''
|
|
918
1095
|
? React.createElement('div', { style: { marginTop: '10px', color: 'var(--dsw-alias-label-error, #b42318)' } }, error)
|
|
919
1096
|
: null,
|
|
1097
|
+
!signedIn && phase !== 'loading'
|
|
1098
|
+
? React.createElement('div', { style: { marginTop: '12px' } },
|
|
1099
|
+
signIn.state === 'pending' || signIn.state === 'starting'
|
|
1100
|
+
? React.createElement('div', null,
|
|
1101
|
+
React.createElement('div', { style: { marginBottom: '8px' } },
|
|
1102
|
+
signIn.state === 'starting' ? 'Asking the GitHub CLI for a code…' : 'Approve this one-time code in your browser:'),
|
|
1103
|
+
signIn.code === ''
|
|
1104
|
+
? null
|
|
1105
|
+
: React.createElement('div', {
|
|
1106
|
+
'data-polish-github': 'code',
|
|
1107
|
+
style: {
|
|
1108
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
|
1109
|
+
fontSize: '18px',
|
|
1110
|
+
letterSpacing: '2px',
|
|
1111
|
+
padding: '8px 10px',
|
|
1112
|
+
borderRadius: 'var(--dsw-radius-sm, 6px)',
|
|
1113
|
+
border: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.12))',
|
|
1114
|
+
},
|
|
1115
|
+
}, signIn.code),
|
|
1116
|
+
React.createElement('div', { style: { display: 'flex', gap: '8px', marginTop: '10px', flexWrap: 'wrap' } },
|
|
1117
|
+
React.createElement('button', { type: 'button', onClick: copyCode, style: GITHUB_BUTTON_STYLE, 'data-polish-github': 'copy' },
|
|
1118
|
+
copied ? 'Copied' : 'Copy code'),
|
|
1119
|
+
React.createElement('a', {
|
|
1120
|
+
href: signIn.url === '' ? 'https://github.com/login/device' : signIn.url,
|
|
1121
|
+
target: '_blank',
|
|
1122
|
+
rel: 'noreferrer',
|
|
1123
|
+
style: { ...GITHUB_BUTTON_STYLE, textDecoration: 'none', display: 'inline-flex', alignItems: 'center' },
|
|
1124
|
+
}, 'Open the device page'),
|
|
1125
|
+
React.createElement('button', { type: 'button', onClick: cancelSignIn, style: GITHUB_BUTTON_STYLE, 'data-polish-github': 'cancel' }, 'Cancel')))
|
|
1126
|
+
: React.createElement('div', null,
|
|
1127
|
+
React.createElement('button', {
|
|
1128
|
+
type: 'button',
|
|
1129
|
+
onClick: beginSignIn,
|
|
1130
|
+
// The attribute belongs on the element: inside `style` it is ignored,
|
|
1131
|
+
// which is exactly why the first verification could not find this button.
|
|
1132
|
+
'data-polish-github': 'signin',
|
|
1133
|
+
style: GITHUB_BUTTON_STYLE,
|
|
1134
|
+
}, 'Sign in with GitHub'),
|
|
1135
|
+
signIn.state === 'failed' && signIn.reason !== ''
|
|
1136
|
+
? React.createElement('div', { style: { marginTop: '8px', color: 'var(--dsw-alias-label-error, #b42318)' } }, signIn.reason)
|
|
1137
|
+
: null))
|
|
1138
|
+
: null,
|
|
920
1139
|
signedIn
|
|
921
1140
|
? React.createElement('div', { style: GITHUB_LIST_STYLE, 'data-polish-github-list': String(visible.length) },
|
|
922
1141
|
phase === 'loading' && visible.length === 0 ? React.createElement('div', { style: { padding: '10px 4px', opacity: 0.7 } }, 'Loading repositories…') : null,
|
|
@@ -961,6 +1180,9 @@ const FOOT_ROW_STYLE = {
|
|
|
961
1180
|
*/
|
|
962
1181
|
function GithubStatus(props) {
|
|
963
1182
|
const enabled = useSwitch(props.configForms, 'githubWorkspace', true)
|
|
1183
|
+
// The dialog publishes every status it reads, so a sign-out in the dialog lands
|
|
1184
|
+
// in this line immediately instead of waiting for the next window focus.
|
|
1185
|
+
const shared = React.useSyncExternalStore(githubStatusStore.subscribe, githubStatusStore.get)
|
|
964
1186
|
const [status, setStatus] = React.useState(undefined)
|
|
965
1187
|
React.useEffect(() => {
|
|
966
1188
|
if (!enabled) return undefined
|
|
@@ -968,7 +1190,11 @@ function GithubStatus(props) {
|
|
|
968
1190
|
const read = () => {
|
|
969
1191
|
Promise.resolve()
|
|
970
1192
|
.then(() => githubCall('status'))
|
|
971
|
-
.then(next => {
|
|
1193
|
+
.then(next => {
|
|
1194
|
+
if (!live) return
|
|
1195
|
+
setStatus(next)
|
|
1196
|
+
githubStatusStore.set(next)
|
|
1197
|
+
})
|
|
972
1198
|
.catch(() => { if (live) setStatus({ signedIn: false }) })
|
|
973
1199
|
}
|
|
974
1200
|
read()
|
|
@@ -980,15 +1206,16 @@ function GithubStatus(props) {
|
|
|
980
1206
|
}
|
|
981
1207
|
}, [enabled])
|
|
982
1208
|
if (!enabled) return null
|
|
983
|
-
const
|
|
984
|
-
const
|
|
1209
|
+
const current = shared === undefined ? status : shared
|
|
1210
|
+
const signedIn = current !== undefined && current.signedIn === true
|
|
1211
|
+
const label = current === undefined
|
|
985
1212
|
? 'GitHub · checking…'
|
|
986
|
-
: signedIn ? `GitHub · ${String(
|
|
1213
|
+
: signedIn ? `GitHub · ${String(current.login)}` : 'GitHub · signed out'
|
|
987
1214
|
return React.createElement('div', { style: { padding: '2px 8px 0', minWidth: 0 } },
|
|
988
1215
|
React.createElement('button', {
|
|
989
1216
|
type: 'button',
|
|
990
1217
|
'data-polish-github': 'status',
|
|
991
|
-
'data-polish-github-state': signedIn ? 'in' :
|
|
1218
|
+
'data-polish-github-state': signedIn ? 'in' : current === undefined ? 'checking' : 'out',
|
|
992
1219
|
title: signedIn
|
|
993
1220
|
? 'Add a workspace from GitHub'
|
|
994
1221
|
: 'Sign in with the GitHub CLI (gh auth login), then add a repository as a workspace',
|
|
@@ -1250,6 +1477,349 @@ function WorkspaceSourceFlow(props) {
|
|
|
1250
1477
|
document.body)
|
|
1251
1478
|
}
|
|
1252
1479
|
|
|
1480
|
+
/**
|
|
1481
|
+
* Right-panel screenshot.
|
|
1482
|
+
*
|
|
1483
|
+
* The Shell offers no way to lift pixels out of itself: there is no capture
|
|
1484
|
+
* bridge on the desktop preload, `getDisplayMedia` has no source to offer, and
|
|
1485
|
+
* the Host has no route to the renderer's pixels. What a client face does have
|
|
1486
|
+
* is the panel's own DOM in the same document, so the picture is rasterised in
|
|
1487
|
+
* the page by the vendored rasteriser. docs/panel-screenshot.md records what
|
|
1488
|
+
* that means for the two iframes and for the window around the app.
|
|
1489
|
+
*/
|
|
1490
|
+
const PANEL_TARGETS = [
|
|
1491
|
+
'[data-sidebar-right-session][data-sidebar-right-open] [data-dockkit-pane-active]',
|
|
1492
|
+
'[data-sidebar-right-session][data-sidebar-right-open]',
|
|
1493
|
+
'[data-dsh-panel][data-dsh-bottom-panel]',
|
|
1494
|
+
]
|
|
1495
|
+
|
|
1496
|
+
/**
|
|
1497
|
+
* The element to photograph: the right panel's open tab, else the panel itself.
|
|
1498
|
+
* @returns the element, or undefined when no panel is open.
|
|
1499
|
+
*/
|
|
1500
|
+
function panelTarget() {
|
|
1501
|
+
for (const selector of PANEL_TARGETS) {
|
|
1502
|
+
const node = document.querySelector(selector)
|
|
1503
|
+
if (node === null) continue
|
|
1504
|
+
const rect = node.getBoundingClientRect()
|
|
1505
|
+
if (rect.width < 48 || rect.height < 48) continue
|
|
1506
|
+
return node
|
|
1507
|
+
}
|
|
1508
|
+
return undefined
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
/**
|
|
1512
|
+
* The first opaque background behind an element, so a transparent panel still
|
|
1513
|
+
* reads as a surface instead of turning into a black or checkered square.
|
|
1514
|
+
* @param node - element whose backdrop is wanted.
|
|
1515
|
+
* @returns a CSS colour.
|
|
1516
|
+
*/
|
|
1517
|
+
function backgroundOf(node) {
|
|
1518
|
+
for (let current = node; current !== null && current !== undefined; current = current.parentElement) {
|
|
1519
|
+
const colour = window.getComputedStyle(current).backgroundColor
|
|
1520
|
+
if (colour !== '' && colour !== 'transparent' && colour !== 'rgba(0, 0, 0, 0)') return colour
|
|
1521
|
+
}
|
|
1522
|
+
return '#ffffff'
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
/**
|
|
1526
|
+
* Rasterise one element to a PNG blob.
|
|
1527
|
+
* @param node - element to draw.
|
|
1528
|
+
* @returns the PNG.
|
|
1529
|
+
*/
|
|
1530
|
+
async function capturePanel(node) {
|
|
1531
|
+
const rasterise = globalThis.html2canvas
|
|
1532
|
+
if (typeof rasterise !== 'function') throw new Error('the screenshot library did not load')
|
|
1533
|
+
const canvas = await rasterise(node, {
|
|
1534
|
+
backgroundColor: backgroundOf(node),
|
|
1535
|
+
// Capped at 2: text stays legible on a HiDPI screen without quadrupling the
|
|
1536
|
+
// file that gets uploaded with the message.
|
|
1537
|
+
scale: Math.min(2, Math.max(1.5, window.devicePixelRatio || 1)),
|
|
1538
|
+
logging: false,
|
|
1539
|
+
useCORS: true,
|
|
1540
|
+
})
|
|
1541
|
+
return await new Promise((resolve, reject) => {
|
|
1542
|
+
canvas.toBlob((value) => {
|
|
1543
|
+
if (value === null) reject(new Error('the panel could not be rasterised'))
|
|
1544
|
+
else resolve(value)
|
|
1545
|
+
}, 'image/png')
|
|
1546
|
+
})
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
/**
|
|
1550
|
+
* Put a PNG on the clipboard.
|
|
1551
|
+
* @param blob - the PNG.
|
|
1552
|
+
* @returns whether the Shell allowed the write.
|
|
1553
|
+
*/
|
|
1554
|
+
async function copyImage(blob) {
|
|
1555
|
+
if (typeof ClipboardItem !== 'function' || navigator.clipboard === undefined) return false
|
|
1556
|
+
try {
|
|
1557
|
+
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
|
|
1558
|
+
return true
|
|
1559
|
+
} catch {
|
|
1560
|
+
return false
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
/**
|
|
1565
|
+
* A file name for the attachment, matching the chat's own timestamped names.
|
|
1566
|
+
* @returns the name.
|
|
1567
|
+
*/
|
|
1568
|
+
function shotName() {
|
|
1569
|
+
const now = new Date()
|
|
1570
|
+
const pad = (value) => String(value).padStart(2, '0')
|
|
1571
|
+
return 'right-panel-' + now.getFullYear() + pad(now.getMonth() + 1) + pad(now.getDate())
|
|
1572
|
+
+ '-' + pad(now.getHours()) + pad(now.getMinutes()) + pad(now.getSeconds()) + '.png'
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
const SHOT_GLYPH_STYLE = { width: '14px', height: '14px', flex: 'none', display: 'block' }
|
|
1576
|
+
|
|
1577
|
+
/**
|
|
1578
|
+
* Camera glyph, drawn here so the face depends on no icon package.
|
|
1579
|
+
* @returns the svg element.
|
|
1580
|
+
*/
|
|
1581
|
+
function CameraGlyph() {
|
|
1582
|
+
return React.createElement('svg', {
|
|
1583
|
+
viewBox: '0 0 16 16',
|
|
1584
|
+
width: 14,
|
|
1585
|
+
height: 14,
|
|
1586
|
+
fill: 'none',
|
|
1587
|
+
stroke: 'currentColor',
|
|
1588
|
+
strokeWidth: 1.2,
|
|
1589
|
+
strokeLinecap: 'round',
|
|
1590
|
+
strokeLinejoin: 'round',
|
|
1591
|
+
'aria-hidden': 'true',
|
|
1592
|
+
style: SHOT_GLYPH_STYLE,
|
|
1593
|
+
},
|
|
1594
|
+
React.createElement('path', { d: 'M2 5.5h2.2l1-1.5h5.6l1 1.5H14v7H2z' }),
|
|
1595
|
+
React.createElement('circle', { cx: 8, cy: 9, r: 2.2 }))
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
/**
|
|
1599
|
+
* Where the camera button is drawn: the panel's own top-right corner, just
|
|
1600
|
+
* inside the frame and left of the Shell's expand/close controls.
|
|
1601
|
+
*/
|
|
1602
|
+
const SHOT_ANCHOR_EDGE = 8
|
|
1603
|
+
const SHOT_ANCHOR_BUTTON = 28
|
|
1604
|
+
const SHOT_ANCHOR_CONTROLS = 96
|
|
1605
|
+
|
|
1606
|
+
/**
|
|
1607
|
+
* Where the open right panel's header ends, in viewport coordinates.
|
|
1608
|
+
* @returns the corner, or undefined when no panel is open.
|
|
1609
|
+
*/
|
|
1610
|
+
function shotAnchor() {
|
|
1611
|
+
const panel = document.querySelector('[data-sidebar-right-session][data-sidebar-right-open]')
|
|
1612
|
+
if (panel === null) return undefined
|
|
1613
|
+
const rect = panel.getBoundingClientRect()
|
|
1614
|
+
if (rect.width < 120 || rect.height < 120) return undefined
|
|
1615
|
+
return {
|
|
1616
|
+
top: rect.top + SHOT_ANCHOR_EDGE,
|
|
1617
|
+
left: Math.max(rect.left + SHOT_ANCHOR_EDGE, rect.right - SHOT_ANCHOR_CONTROLS - SHOT_ANCHOR_BUTTON),
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
/**
|
|
1622
|
+
* The composer context the overlay button attaches through.
|
|
1623
|
+
*
|
|
1624
|
+
* The camera button lives in a root overlay so it can sit in the panel header,
|
|
1625
|
+
* but attaching an image needs the *session-scoped* composer props (`sessionId`
|
|
1626
|
+
* and `inputActions`), which only a session slot receives. The composer dock is
|
|
1627
|
+
* therefore kept as an invisible publisher and this store carries its context up
|
|
1628
|
+
* to the overlay; with no open Session the store is empty and the button hides.
|
|
1629
|
+
*/
|
|
1630
|
+
const shotStore = (() => {
|
|
1631
|
+
const listeners = new Set()
|
|
1632
|
+
let value
|
|
1633
|
+
return {
|
|
1634
|
+
get: () => value,
|
|
1635
|
+
set: (next) => {
|
|
1636
|
+
value = next
|
|
1637
|
+
listeners.forEach((listener) => listener())
|
|
1638
|
+
},
|
|
1639
|
+
subscribe: (listener) => {
|
|
1640
|
+
listeners.add(listener)
|
|
1641
|
+
return () => listeners.delete(listener)
|
|
1642
|
+
},
|
|
1643
|
+
}
|
|
1644
|
+
})()
|
|
1645
|
+
|
|
1646
|
+
const SHOT_BUTTON_STYLE = {
|
|
1647
|
+
position: 'fixed',
|
|
1648
|
+
display: 'inline-flex',
|
|
1649
|
+
alignItems: 'center',
|
|
1650
|
+
justifyContent: 'center',
|
|
1651
|
+
width: SHOT_ANCHOR_BUTTON + 'px',
|
|
1652
|
+
height: SHOT_ANCHOR_BUTTON + 'px',
|
|
1653
|
+
padding: '0',
|
|
1654
|
+
border: 'none',
|
|
1655
|
+
borderRadius: 'var(--dsw-radius-sm, 6px)',
|
|
1656
|
+
background: 'transparent',
|
|
1657
|
+
color: 'var(--dsw-alias-label-tertiary, #61666b)',
|
|
1658
|
+
cursor: 'pointer',
|
|
1659
|
+
zIndex: 40,
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
/**
|
|
1663
|
+
* Invisible composer-dock entry that publishes the session context the overlay
|
|
1664
|
+
* button needs, and nothing else.
|
|
1665
|
+
* @param props - session slot props (`sessionId`, `inputActions`) plus the
|
|
1666
|
+
* late `conversation` lookup.
|
|
1667
|
+
* @returns null.
|
|
1668
|
+
*/
|
|
1669
|
+
function PanelShotContext(props) {
|
|
1670
|
+
React.useEffect(() => {
|
|
1671
|
+
shotStore.set({
|
|
1672
|
+
sessionId: props.sessionId,
|
|
1673
|
+
actions: props.inputActions,
|
|
1674
|
+
conversation: props.conversation,
|
|
1675
|
+
})
|
|
1676
|
+
return () => shotStore.set(undefined)
|
|
1677
|
+
}, [props.sessionId])
|
|
1678
|
+
return null
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
/**
|
|
1682
|
+
* Camera button pinned to the right panel's top-right corner: photographs the
|
|
1683
|
+
* open tab, copies it, and attaches it to the message being written.
|
|
1684
|
+
* @param props - slot props plus the late `conversation` lookup.
|
|
1685
|
+
* @returns the button, or null while there is nothing to shoot or nowhere to
|
|
1686
|
+
* attach.
|
|
1687
|
+
*/
|
|
1688
|
+
function PanelShotButton(props) {
|
|
1689
|
+
const on = useSwitch(props.configForms, 'panelScreenshot', true)
|
|
1690
|
+
const context = React.useSyncExternalStore(shotStore.subscribe, shotStore.get)
|
|
1691
|
+
const [anchor, setAnchor] = React.useState(() => shotAnchor())
|
|
1692
|
+
const [phase, setPhase] = React.useState('idle')
|
|
1693
|
+
const [note, setNote] = React.useState('')
|
|
1694
|
+
React.useEffect(() => {
|
|
1695
|
+
const measure = () => setAnchor(shotAnchor())
|
|
1696
|
+
measure()
|
|
1697
|
+
// The panel opens, closes, splits and resizes without any event this
|
|
1698
|
+
// component would otherwise hear, so watch the attribute and the geometry.
|
|
1699
|
+
const observer = new MutationObserver(measure)
|
|
1700
|
+
observer.observe(document.body, { attributes: true, subtree: true, attributeFilter: ['data-sidebar-right-open', 'data-dockkit-pane-active'] })
|
|
1701
|
+
window.addEventListener('resize', measure)
|
|
1702
|
+
return () => {
|
|
1703
|
+
observer.disconnect()
|
|
1704
|
+
window.removeEventListener('resize', measure)
|
|
1705
|
+
}
|
|
1706
|
+
}, [])
|
|
1707
|
+
React.useEffect(() => {
|
|
1708
|
+
if (note === '') return undefined
|
|
1709
|
+
const timer = setTimeout(() => {
|
|
1710
|
+
setNote('')
|
|
1711
|
+
setPhase('idle')
|
|
1712
|
+
}, 4000)
|
|
1713
|
+
return () => clearTimeout(timer)
|
|
1714
|
+
}, [note])
|
|
1715
|
+
if (!on || context === undefined || anchor === undefined) return null
|
|
1716
|
+
const take = async () => {
|
|
1717
|
+
if (phase === 'busy') return
|
|
1718
|
+
const node = panelTarget()
|
|
1719
|
+
if (node === undefined) {
|
|
1720
|
+
setPhase('failed')
|
|
1721
|
+
setNote('open a panel on the right first')
|
|
1722
|
+
return
|
|
1723
|
+
}
|
|
1724
|
+
setPhase('busy')
|
|
1725
|
+
setNote('')
|
|
1726
|
+
try {
|
|
1727
|
+
const blob = await capturePanel(node)
|
|
1728
|
+
const copied = await copyImage(blob)
|
|
1729
|
+
const conversation = context.conversation()
|
|
1730
|
+
if (conversation === undefined) throw new Error('the chat is not available')
|
|
1731
|
+
// Two calls, because the Shell splits the job: `createDrafts` mints a
|
|
1732
|
+
// draft attachment and starts its upload (it is not on the public
|
|
1733
|
+
// `IConversation` type, but it is the only File -> id entry point and the
|
|
1734
|
+
// product reaches it the same way), and the composer's own `inputActions`
|
|
1735
|
+
// puts those ids in the message. Skipping the second call leaves the
|
|
1736
|
+
// image uploaded but attached to nothing.
|
|
1737
|
+
const drafts = conversation.createDrafts(
|
|
1738
|
+
context.sessionId,
|
|
1739
|
+
[new File([blob], shotName(), { type: 'image/png' })],
|
|
1740
|
+
true,
|
|
1741
|
+
)
|
|
1742
|
+
if (drafts === undefined || drafts.length === 0) throw new Error('the chat refused the image')
|
|
1743
|
+
const actions = context.actions
|
|
1744
|
+
if (actions === undefined || typeof actions.addAttachments !== 'function') {
|
|
1745
|
+
throw new Error('this Shell offers no composer to attach to')
|
|
1746
|
+
}
|
|
1747
|
+
if (!actions.addAttachments(drafts.map((draft) => draft.id))) {
|
|
1748
|
+
throw new Error('the composer refused the image')
|
|
1749
|
+
}
|
|
1750
|
+
setPhase('done')
|
|
1751
|
+
setNote(copied ? 'right panel copied and attached' : 'right panel attached, clipboard refused')
|
|
1752
|
+
} catch (error) {
|
|
1753
|
+
setPhase('failed')
|
|
1754
|
+
setNote(error instanceof Error ? error.message : String(error))
|
|
1755
|
+
console.warn('[aryacode-polish] panel screenshot failed:', error)
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
const label = phase === 'done' ? '✓' : phase === 'failed' ? '!' : '●'
|
|
1759
|
+
return React.createElement(React.Fragment, null,
|
|
1760
|
+
React.createElement('span', {
|
|
1761
|
+
'data-polish-shot-anchor': 'panel',
|
|
1762
|
+
style: { position: 'fixed', top: anchor.top + 'px', left: anchor.left + 'px', width: '0', height: '0' },
|
|
1763
|
+
}),
|
|
1764
|
+
React.createElement('button', {
|
|
1765
|
+
type: 'button',
|
|
1766
|
+
'data-polish-shot': phase,
|
|
1767
|
+
title: note === '' ? 'Screenshot this panel into the chat (also copies it)' : note,
|
|
1768
|
+
'aria-label': 'Screenshot this panel',
|
|
1769
|
+
disabled: phase === 'busy',
|
|
1770
|
+
onClick: () => { void take() },
|
|
1771
|
+
onMouseEnter: (event) => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
|
|
1772
|
+
onMouseLeave: (event) => { event.currentTarget.style.background = 'transparent' },
|
|
1773
|
+
style: {
|
|
1774
|
+
...SHOT_BUTTON_STYLE,
|
|
1775
|
+
top: anchor.top + 'px',
|
|
1776
|
+
left: anchor.left + 'px',
|
|
1777
|
+
color: phase === 'failed'
|
|
1778
|
+
? 'var(--dsw-alias-label-error, #b42318)'
|
|
1779
|
+
: 'var(--dsw-alias-label-tertiary, #61666b)',
|
|
1780
|
+
opacity: phase === 'busy' ? 0.5 : 1,
|
|
1781
|
+
cursor: phase === 'busy' ? 'progress' : 'pointer',
|
|
1782
|
+
fontSize: '11px',
|
|
1783
|
+
},
|
|
1784
|
+
},
|
|
1785
|
+
phase === 'done' || phase === 'failed'
|
|
1786
|
+
? React.createElement('span', { 'aria-hidden': 'true' }, label)
|
|
1787
|
+
: React.createElement(CameraGlyph, null)),
|
|
1788
|
+
note === ''
|
|
1789
|
+
? null
|
|
1790
|
+
: React.createElement('span', {
|
|
1791
|
+
'data-polish-shot-note': phase,
|
|
1792
|
+
style: {
|
|
1793
|
+
position: 'fixed',
|
|
1794
|
+
top: anchor.top + SHOT_ANCHOR_BUTTON + 6 + 'px',
|
|
1795
|
+
left: Math.max(12, anchor.left - 140) + 'px',
|
|
1796
|
+
maxWidth: '260px',
|
|
1797
|
+
padding: '3px 8px',
|
|
1798
|
+
borderRadius: 'var(--dsw-radius-sm, 6px)',
|
|
1799
|
+
background: 'Canvas',
|
|
1800
|
+
boxShadow: 'var(--dsw-elevation-prominent, 0 3px 8px rgba(0,0,0,0.12))',
|
|
1801
|
+
color: phase === 'failed' ? 'var(--dsw-alias-label-error, #b42318)' : 'var(--dsw-alias-label-secondary, #61666b)',
|
|
1802
|
+
fontSize: '12px',
|
|
1803
|
+
lineHeight: '18px',
|
|
1804
|
+
whiteSpace: 'nowrap',
|
|
1805
|
+
zIndex: 41,
|
|
1806
|
+
},
|
|
1807
|
+
}, note))
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
/**
|
|
1811
|
+
* Everything this bundle draws at the root: the GitHub dialog and the right
|
|
1812
|
+
* panel's camera button. `shell.overlay` is a single slot, so both live in one
|
|
1813
|
+
* registration instead of competing for it.
|
|
1814
|
+
* @param props - overlay slot props (`configForms`, `createWorkspace`).
|
|
1815
|
+
* @returns the fragment.
|
|
1816
|
+
*/
|
|
1817
|
+
function ShellOverlay(props) {
|
|
1818
|
+
return React.createElement(React.Fragment, null,
|
|
1819
|
+
React.createElement(GitHubPanel, props),
|
|
1820
|
+
React.createElement(PanelShotButton, props))
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1253
1823
|
return {
|
|
1254
1824
|
inject: ['slots'],
|
|
1255
1825
|
/**
|
|
@@ -1275,6 +1845,25 @@ return {
|
|
|
1275
1845
|
order: 2,
|
|
1276
1846
|
inject: () => ({ configForms }),
|
|
1277
1847
|
}, CostPill))
|
|
1848
|
+
// The camera button belongs to the panel header, which is a root surface, so
|
|
1849
|
+
// the composer dock only publishes the session context it needs. The chat
|
|
1850
|
+
// service is looked up late through ctx.get: it is a plain service, and a
|
|
1851
|
+
// Shell without it simply reports "the chat is not available".
|
|
1852
|
+
ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({
|
|
1853
|
+
name: 'conversation.composer.dock',
|
|
1854
|
+
id: 'polish-shot',
|
|
1855
|
+
order: 3,
|
|
1856
|
+
inject: () => ({
|
|
1857
|
+
configForms,
|
|
1858
|
+
conversation: () => {
|
|
1859
|
+
try {
|
|
1860
|
+
return ctx.get('conversation')
|
|
1861
|
+
} catch {
|
|
1862
|
+
return undefined
|
|
1863
|
+
}
|
|
1864
|
+
},
|
|
1865
|
+
}),
|
|
1866
|
+
}, PanelShotContext))
|
|
1278
1867
|
// The account Remote namespace is optional: a Shell with no account still gets
|
|
1279
1868
|
// the Configure page and the cost pill, and only loses the balance line.
|
|
1280
1869
|
ctx.inject(['remote', 'remote.account'], (accountCtx) => {
|
|
@@ -1327,7 +1916,7 @@ return {
|
|
|
1327
1916
|
name: 'shell.overlay',
|
|
1328
1917
|
id: 'polish-github',
|
|
1329
1918
|
inject: () => ({ configForms, createWorkspace }),
|
|
1330
|
-
},
|
|
1919
|
+
}, ShellOverlay))
|
|
1331
1920
|
// The footer keeps the sign-in state, not an add button: adding a
|
|
1332
1921
|
// workspace belongs in the "+" menu, where the Shell says it does.
|
|
1333
1922
|
githubCtx.slots.inject('sidebar.footer.action', () => githubCtx.slots.register({
|