@celljs/ta-web 4.6.1 → 4.7.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/README.en.md +90 -0
- package/README.md +12 -6
- package/esm/browser/chat/chat-view.js +1 -1
- package/esm/browser/chat/chat-view.js.map +1 -1
- package/esm/browser/components/account-menu.d.ts +3 -2
- package/esm/browser/components/account-menu.js +21 -16
- package/esm/browser/components/account-menu.js.map +1 -1
- package/esm/browser/components/app-view.js +3 -1
- package/esm/browser/components/app-view.js.map +1 -1
- package/esm/browser/components/chat-list.d.ts +3 -1
- package/esm/browser/components/chat-list.js +6 -1
- package/esm/browser/components/chat-list.js.map +1 -1
- package/esm/browser/components/folder-picker.d.ts +37 -6
- package/esm/browser/components/folder-picker.js +155 -33
- package/esm/browser/components/folder-picker.js.map +1 -1
- package/esm/browser/components/remote-hosts.d.ts +7 -0
- package/esm/browser/components/remote-hosts.js +94 -0
- package/esm/browser/components/remote-hosts.js.map +1 -0
- package/esm/browser/components/remote-prompts.d.ts +7 -0
- package/esm/browser/components/remote-prompts.js +72 -0
- package/esm/browser/components/remote-prompts.js.map +1 -0
- package/esm/browser/components/settings-view.js +8 -1
- package/esm/browser/components/settings-view.js.map +1 -1
- package/esm/browser/components/web-composer.d.ts +4 -1
- package/esm/browser/components/web-composer.js +2 -2
- package/esm/browser/components/web-composer.js.map +1 -1
- package/esm/browser/components/working-directory.js +1 -1
- package/esm/browser/components/working-directory.js.map +1 -1
- package/esm/browser/extension/icons.js +3 -1
- package/esm/browser/extension/icons.js.map +1 -1
- package/esm/browser/extension/web-extension-api.d.ts +4 -2
- package/esm/browser/extension/web-extensions.js +11 -1
- package/esm/browser/extension/web-extensions.js.map +1 -1
- package/esm/browser/i18n/locales.d.ts +32 -1
- package/esm/browser/i18n/messages.d.ts +31 -0
- package/esm/browser/i18n/messages.js +75 -13
- package/esm/browser/i18n/messages.js.map +1 -1
- package/esm/browser/style/base.d.ts +1 -1
- package/esm/browser/style/base.js +6 -1
- package/esm/browser/style/base.js.map +1 -1
- package/esm/browser/style/layout.d.ts +1 -1
- package/esm/browser/style/layout.js +48 -15
- package/esm/browser/style/layout.js.map +1 -1
- package/esm/browser/util/message.d.ts +1 -0
- package/esm/browser/util/message.js +7 -5
- package/esm/browser/util/message.js.map +1 -1
- package/esm/browser/workbench-protocol.d.ts +7 -1
- package/esm/browser/workbench-protocol.js.map +1 -1
- package/esm/browser/workbench.d.ts +7 -1
- package/esm/browser/workbench.js +23 -0
- package/esm/browser/workbench.js.map +1 -1
- package/lib/browser/chat/chat-view.js +1 -1
- package/lib/browser/chat/chat-view.js.map +1 -1
- package/lib/browser/components/account-menu.d.ts +3 -2
- package/lib/browser/components/account-menu.d.ts.map +1 -1
- package/lib/browser/components/account-menu.js +21 -16
- package/lib/browser/components/account-menu.js.map +1 -1
- package/lib/browser/components/app-view.d.ts.map +1 -1
- package/lib/browser/components/app-view.js +3 -1
- package/lib/browser/components/app-view.js.map +1 -1
- package/lib/browser/components/chat-list.d.ts +3 -1
- package/lib/browser/components/chat-list.d.ts.map +1 -1
- package/lib/browser/components/chat-list.js +6 -1
- package/lib/browser/components/chat-list.js.map +1 -1
- package/lib/browser/components/folder-picker.d.ts +37 -6
- package/lib/browser/components/folder-picker.d.ts.map +1 -1
- package/lib/browser/components/folder-picker.js +156 -33
- package/lib/browser/components/folder-picker.js.map +1 -1
- package/lib/browser/components/folder-picker.spec.js +15 -0
- package/lib/browser/components/folder-picker.spec.js.map +1 -1
- package/lib/browser/components/remote-hosts.d.ts +8 -0
- package/lib/browser/components/remote-hosts.d.ts.map +1 -0
- package/lib/browser/components/remote-hosts.js +130 -0
- package/lib/browser/components/remote-hosts.js.map +1 -0
- package/lib/browser/components/remote-prompts.d.ts +8 -0
- package/lib/browser/components/remote-prompts.d.ts.map +1 -0
- package/lib/browser/components/remote-prompts.js +108 -0
- package/lib/browser/components/remote-prompts.js.map +1 -0
- package/lib/browser/components/settings-view.d.ts.map +1 -1
- package/lib/browser/components/settings-view.js +7 -0
- package/lib/browser/components/settings-view.js.map +1 -1
- package/lib/browser/components/web-composer.d.ts +4 -1
- package/lib/browser/components/web-composer.d.ts.map +1 -1
- package/lib/browser/components/web-composer.js +2 -2
- package/lib/browser/components/web-composer.js.map +1 -1
- package/lib/browser/components/working-directory.js +1 -1
- package/lib/browser/components/working-directory.js.map +1 -1
- package/lib/browser/extension/icons.d.ts.map +1 -1
- package/lib/browser/extension/icons.js +2 -0
- package/lib/browser/extension/icons.js.map +1 -1
- package/lib/browser/extension/web-extension-api.d.ts +4 -2
- package/lib/browser/extension/web-extension-api.d.ts.map +1 -1
- package/lib/browser/extension/web-extensions.d.ts.map +1 -1
- package/lib/browser/extension/web-extensions.js +11 -1
- package/lib/browser/extension/web-extensions.js.map +1 -1
- package/lib/browser/i18n/locales.d.ts +32 -1
- package/lib/browser/i18n/locales.d.ts.map +1 -1
- package/lib/browser/i18n/messages.d.ts +31 -0
- package/lib/browser/i18n/messages.d.ts.map +1 -1
- package/lib/browser/i18n/messages.js +75 -13
- package/lib/browser/i18n/messages.js.map +1 -1
- package/lib/browser/notices/notices.spec.js +1 -1
- package/lib/browser/notices/notices.spec.js.map +1 -1
- package/lib/browser/style/base.d.ts +1 -1
- package/lib/browser/style/base.d.ts.map +1 -1
- package/lib/browser/style/base.js +6 -1
- package/lib/browser/style/base.js.map +1 -1
- package/lib/browser/style/layout.d.ts +1 -1
- package/lib/browser/style/layout.d.ts.map +1 -1
- package/lib/browser/style/layout.js +48 -15
- package/lib/browser/style/layout.js.map +1 -1
- package/lib/browser/util/message.d.ts +1 -0
- package/lib/browser/util/message.d.ts.map +1 -1
- package/lib/browser/util/message.js +7 -5
- package/lib/browser/util/message.js.map +1 -1
- package/lib/browser/util/message.spec.js +2 -0
- package/lib/browser/util/message.spec.js.map +1 -1
- package/lib/browser/workbench-protocol.d.ts +7 -1
- package/lib/browser/workbench-protocol.d.ts.map +1 -1
- package/lib/browser/workbench-protocol.js.map +1 -1
- package/lib/browser/workbench.d.ts +7 -1
- package/lib/browser/workbench.d.ts.map +1 -1
- package/lib/browser/workbench.js +23 -0
- package/lib/browser/workbench.js.map +1 -1
- package/package.json +7 -7
- package/src/browser/chat/chat-view.tsx +1 -1
- package/src/browser/components/account-menu.tsx +23 -20
- package/src/browser/components/app-view.tsx +2 -0
- package/src/browser/components/chat-list.tsx +9 -2
- package/src/browser/components/folder-picker.spec.ts +17 -0
- package/src/browser/components/folder-picker.tsx +221 -39
- package/src/browser/components/remote-hosts.tsx +149 -0
- package/src/browser/components/remote-prompts.tsx +111 -0
- package/src/browser/components/settings-view.tsx +8 -0
- package/src/browser/components/web-composer.tsx +17 -3
- package/src/browser/components/working-directory.tsx +1 -1
- package/src/browser/extension/icons.ts +4 -0
- package/src/browser/extension/web-extension-api.ts +4 -2
- package/src/browser/extension/web-extensions.ts +12 -2
- package/src/browser/i18n/messages.ts +75 -13
- package/src/browser/notices/notices.spec.ts +1 -1
- package/src/browser/style/base.ts +6 -1
- package/src/browser/style/layout.ts +48 -15
- package/src/browser/util/message.spec.ts +2 -0
- package/src/browser/util/message.ts +7 -4
- package/src/browser/workbench-protocol.ts +8 -0
- package/src/browser/workbench.ts +29 -0
|
@@ -1,75 +1,230 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import { ChevronDown, ChevronRight, CornerLeftUp, Folder, X } from 'lucide-react';
|
|
1
|
+
import { type TaDirEntry, TaLocation, type TaRemoteEvent, type TaRemoteHost, type TaRemoteServer } from '@celljs/ta-protocol';
|
|
2
|
+
import { ChevronDown, ChevronRight, CornerLeftUp, Folder, Monitor, Plus, Server, X } from 'lucide-react';
|
|
3
3
|
import * as React from 'react';
|
|
4
4
|
import { createPortal } from 'react-dom';
|
|
5
|
-
import { useT } from '../i18n/use-t';
|
|
5
|
+
import { type Translate, useT } from '../i18n/use-t';
|
|
6
6
|
import { MessageUtil } from '../util/message';
|
|
7
7
|
import { Icon, IconButton } from './icon';
|
|
8
8
|
|
|
9
|
+
/** The hosts of ssh, for a picker that offers them: TaWorkbench has it all (TaRemoteServer and its events). */
|
|
10
|
+
export interface FolderPickerRemote {
|
|
11
|
+
readonly remote: Pick<TaRemoteServer, 'listHosts' | 'resolveLocation'>;
|
|
12
|
+
readonly state: { server?: { cwd: string } };
|
|
13
|
+
onRemoteEvent(listener: (event: TaRemoteEvent) => void): () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export namespace RemoteHostsUtil {
|
|
17
|
+
/** Why what the user typed is no host, in their words. */
|
|
18
|
+
export function problem(read: Exclude<TaLocation.HostInput, { host: string }>, input: string, t: Translate): string {
|
|
19
|
+
return read.error === 'options' ? t('ta.remote.options', { options: read.options.join(' ') }) : t('ta.remote.invalid', { host: input.trim() });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
9
23
|
export namespace FolderPickerUtil {
|
|
10
|
-
/** The directory above
|
|
24
|
+
/** The directory above; undefined at a root (`/`, `C:\`, the root of a host). */
|
|
11
25
|
export function parent(path: string): string | undefined {
|
|
12
|
-
|
|
13
|
-
const at = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'));
|
|
14
|
-
if (at < 0) {
|
|
15
|
-
return undefined;
|
|
16
|
-
}
|
|
17
|
-
const head = trimmed.slice(0, at);
|
|
18
|
-
return head === '' || /^[A-Za-z]:$/.test(head) ? head + trimmed[at] : head;
|
|
26
|
+
return TaLocation.parent(path);
|
|
19
27
|
}
|
|
20
28
|
|
|
21
29
|
export function child(path: string, name: string): string {
|
|
22
|
-
|
|
23
|
-
return path.endsWith(separator) ? path + name : path + separator + name;
|
|
30
|
+
return TaLocation.child(path, name);
|
|
24
31
|
}
|
|
25
32
|
|
|
26
33
|
/** The folders to offer: no files, no hidden ones (a path typed in still opens those). */
|
|
27
34
|
export function folders(entries: TaDirEntry[]): TaDirEntry[] {
|
|
28
35
|
return entries.filter(entry => entry.kind === 'directory' && !entry.name.startsWith('.'));
|
|
29
36
|
}
|
|
37
|
+
|
|
38
|
+
/** Where the tab of this computer starts: `initial` when it is a path of it, else the server's, else the root. */
|
|
39
|
+
export function localStart(initial: string, serverCwd: string | undefined): string {
|
|
40
|
+
if (!TaLocation.isRemote(initial)) {
|
|
41
|
+
return initial;
|
|
42
|
+
}
|
|
43
|
+
return serverCwd && !TaLocation.isRemote(serverCwd) ? serverCwd : '/';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** What the user typed, as a location of the machine shown: a path there, or a whole location. */
|
|
47
|
+
export function typed(input: string, host: string | undefined): string {
|
|
48
|
+
const text = input.trim();
|
|
49
|
+
return host && !TaLocation.isRemote(text) ? TaLocation.ssh(host, text.startsWith('/') ? text : `/${text}`) : text;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** What the host of the picker is doing while it connects, from the events of TaRemoteServer. */
|
|
54
|
+
function useConnecting(bench: FolderPickerRemote | undefined, host: string | undefined): string | undefined {
|
|
55
|
+
const t = useT();
|
|
56
|
+
const [step, setStep] = React.useState<string>();
|
|
57
|
+
React.useEffect(() => {
|
|
58
|
+
setStep(undefined);
|
|
59
|
+
return bench?.onRemoteEvent((event: TaRemoteEvent) => {
|
|
60
|
+
if (event.type === 'progress' && event.host === host) {
|
|
61
|
+
setStep(t(`ta.remote.step.${event.step}`, { host, detail: event.detail ?? '' }));
|
|
62
|
+
} else if (event.type === 'connection' && event.host === host) {
|
|
63
|
+
setStep(undefined);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}, [bench, host, t]);
|
|
67
|
+
return step;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface MachinesProps {
|
|
71
|
+
host: string | undefined;
|
|
72
|
+
hosts: TaRemoteHost[];
|
|
73
|
+
onPick(host: string | undefined): void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** This computer and the hosts of ssh, as tabs; `+` names another host. */
|
|
77
|
+
function Machines({ host, hosts, onPick }: MachinesProps) {
|
|
78
|
+
const t = useT();
|
|
79
|
+
const [adding, setAdding] = React.useState(false);
|
|
80
|
+
const [name, setName] = React.useState('');
|
|
81
|
+
const shown = host && !hosts.some(known => known.host === host) ? [{ host, source: 'recent' as const, connected: false }, ...hosts] : hosts;
|
|
82
|
+
const [problem, setProblem] = React.useState<string>();
|
|
83
|
+
const add = () => {
|
|
84
|
+
const read = TaLocation.readHost(name);
|
|
85
|
+
if ('error' in read) {
|
|
86
|
+
setProblem(RemoteHostsUtil.problem(read, name, t));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
setAdding(false);
|
|
90
|
+
setName('');
|
|
91
|
+
setProblem(undefined);
|
|
92
|
+
onPick(read.host);
|
|
93
|
+
};
|
|
94
|
+
return (
|
|
95
|
+
<>
|
|
96
|
+
<div className="ta-chips ta-folder-machines" role="tablist" aria-label={t('ta.remote.local')}>
|
|
97
|
+
<button type="button" role="tab" aria-selected={!host} className="ta-chip-filter" onClick={() => onPick(undefined)}>
|
|
98
|
+
<Icon icon={Monitor} size={13} />
|
|
99
|
+
{t('ta.remote.local')}
|
|
100
|
+
</button>
|
|
101
|
+
{shown.map(known => (
|
|
102
|
+
<button
|
|
103
|
+
key={known.host}
|
|
104
|
+
type="button"
|
|
105
|
+
role="tab"
|
|
106
|
+
aria-selected={known.host === host}
|
|
107
|
+
className="ta-chip-filter"
|
|
108
|
+
title={known.connected ? `${known.host} · ${t('ta.remote.connected')}` : known.host}
|
|
109
|
+
onClick={() => onPick(known.host)}
|
|
110
|
+
>
|
|
111
|
+
<Icon icon={Server} size={13} />
|
|
112
|
+
{known.host}
|
|
113
|
+
{known.connected && <span className="ta-remote-dot" />}
|
|
114
|
+
</button>
|
|
115
|
+
))}
|
|
116
|
+
{adding ? (
|
|
117
|
+
<form
|
|
118
|
+
className="ta-folder-add"
|
|
119
|
+
onSubmit={event => {
|
|
120
|
+
event.preventDefault();
|
|
121
|
+
add();
|
|
122
|
+
}}
|
|
123
|
+
>
|
|
124
|
+
<input
|
|
125
|
+
autoFocus
|
|
126
|
+
spellCheck={false}
|
|
127
|
+
value={name}
|
|
128
|
+
placeholder={t('ta.remote.addHint')}
|
|
129
|
+
aria-label={t('ta.remote.add')}
|
|
130
|
+
aria-invalid={problem ? true : undefined}
|
|
131
|
+
onChange={event => {
|
|
132
|
+
setName(event.target.value);
|
|
133
|
+
setProblem(undefined);
|
|
134
|
+
}}
|
|
135
|
+
onBlur={() => !name.trim() && setAdding(false)}
|
|
136
|
+
onKeyDown={event => {
|
|
137
|
+
if (event.key === 'Escape') {
|
|
138
|
+
event.stopPropagation();
|
|
139
|
+
setAdding(false);
|
|
140
|
+
}
|
|
141
|
+
}}
|
|
142
|
+
/>
|
|
143
|
+
</form>
|
|
144
|
+
) : (
|
|
145
|
+
<IconButton icon={Plus} label={t('ta.remote.add')} onClick={() => setAdding(true)} />
|
|
146
|
+
)}
|
|
147
|
+
</div>
|
|
148
|
+
{problem && <div className="ta-folder-failed">{problem}</div>}
|
|
149
|
+
</>
|
|
150
|
+
);
|
|
30
151
|
}
|
|
31
152
|
|
|
32
153
|
export interface FolderPickerProps {
|
|
33
154
|
/** The entries of a directory of the server. */
|
|
34
155
|
list(directory: string): Promise<TaDirEntry[]>;
|
|
35
|
-
/** Where it opens. */
|
|
156
|
+
/** Where it opens: a path, or an `ssh://` location. */
|
|
36
157
|
initial: string;
|
|
37
158
|
onPick(path: string): Promise<void>;
|
|
38
159
|
onClose(): void;
|
|
160
|
+
/** Offers the hosts of ssh too. */
|
|
161
|
+
remote?: FolderPickerRemote;
|
|
39
162
|
}
|
|
40
163
|
|
|
41
|
-
/**
|
|
42
|
-
|
|
164
|
+
/**
|
|
165
|
+
* Picks a directory, of the machine of the server or of a host it reaches with ssh, folder by folder or by
|
|
166
|
+
* a path typed in, to open a conversation there.
|
|
167
|
+
*/
|
|
168
|
+
export function FolderPicker({ list, initial, onPick, onClose, remote: bench }: FolderPickerProps): React.ReactElement {
|
|
43
169
|
const t = useT();
|
|
44
170
|
const [path, setPath] = React.useState(initial);
|
|
45
|
-
const [input, setInput] = React.useState(initial);
|
|
171
|
+
const [input, setInput] = React.useState(TaLocation.path(initial));
|
|
46
172
|
const [folders, setFolders] = React.useState<TaDirEntry[]>();
|
|
47
173
|
const [failed, setFailed] = React.useState<string>();
|
|
48
174
|
const [busy, setBusy] = React.useState(false);
|
|
175
|
+
const [hosts, setHosts] = React.useState<TaRemoteHost[]>([]);
|
|
176
|
+
/** The machine being shown, even before its first directory came. */
|
|
177
|
+
const [host, setHost] = React.useState(TaLocation.host(initial));
|
|
49
178
|
/** A directory is being read: picking now would take the one before. */
|
|
50
179
|
const [loading, setLoading] = React.useState(true);
|
|
180
|
+
const connecting = useConnecting(bench, loading ? host : undefined);
|
|
51
181
|
const latest = React.useRef(0);
|
|
182
|
+
/** The machine whose folders are listed: a failure on another one leaves none listed. */
|
|
183
|
+
const listed = React.useRef<string | undefined>(undefined);
|
|
184
|
+
/** Where `This computer` goes: the directory of this machine shown last. */
|
|
185
|
+
const local = React.useRef(FolderPickerUtil.localStart(initial, bench?.state.server?.cwd));
|
|
52
186
|
const go = React.useCallback(
|
|
53
187
|
(next: string) => {
|
|
54
188
|
const request = ++latest.current;
|
|
55
189
|
setFailed(undefined);
|
|
56
190
|
setLoading(true);
|
|
57
|
-
|
|
191
|
+
setHost(TaLocation.host(next));
|
|
192
|
+
setInput(TaLocation.path(next));
|
|
193
|
+
// A host's home directory is known once connected.
|
|
194
|
+
const resolved = bench && TaLocation.parse(next)?.path === '' ? bench.remote.resolveLocation(next) : Promise.resolve(next);
|
|
195
|
+
resolved
|
|
196
|
+
.then(async location => ({ location, entries: await list(location) }))
|
|
58
197
|
.then(
|
|
59
|
-
entries => {
|
|
198
|
+
({ location, entries }) => {
|
|
60
199
|
if (request === latest.current) {
|
|
61
|
-
setPath(
|
|
62
|
-
setInput(
|
|
200
|
+
setPath(location);
|
|
201
|
+
setInput(TaLocation.path(location));
|
|
63
202
|
setFolders(FolderPickerUtil.folders(entries));
|
|
203
|
+
listed.current = TaLocation.host(location);
|
|
204
|
+
if (!TaLocation.isRemote(location)) {
|
|
205
|
+
local.current = location;
|
|
206
|
+
}
|
|
64
207
|
}
|
|
65
208
|
},
|
|
66
|
-
|
|
209
|
+
error => {
|
|
210
|
+
if (request !== latest.current) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const host = TaLocation.host(next);
|
|
214
|
+
if (host !== listed.current) {
|
|
215
|
+
setFolders(undefined);
|
|
216
|
+
}
|
|
217
|
+
setFailed(host && error instanceof Error ? error.message : t('ta.folder.failed', { path: next }));
|
|
218
|
+
},
|
|
67
219
|
)
|
|
68
220
|
.finally(() => request === latest.current && setLoading(false));
|
|
69
221
|
},
|
|
70
|
-
[list],
|
|
222
|
+
[bench, list, t],
|
|
71
223
|
);
|
|
72
224
|
React.useEffect(() => go(initial), [go, initial]);
|
|
225
|
+
React.useEffect(() => {
|
|
226
|
+
bench?.remote.listHosts().then(setHosts, () => undefined);
|
|
227
|
+
}, [bench]);
|
|
73
228
|
const up = FolderPickerUtil.parent(path);
|
|
74
229
|
const pick = () => {
|
|
75
230
|
setBusy(true);
|
|
@@ -89,12 +244,13 @@ export function FolderPicker({ list, initial, onPick, onClose }: FolderPickerPro
|
|
|
89
244
|
<span className="ta-modal-title">{t('ta.folder.title')}</span>
|
|
90
245
|
<IconButton icon={X} label={t('ta.close')} onClick={onClose} />
|
|
91
246
|
</div>
|
|
247
|
+
{bench && <Machines host={host} hosts={hosts} onPick={next => go(next ? TaLocation.ssh(next) : local.current)} />}
|
|
92
248
|
<form
|
|
93
249
|
className="ta-folder-bar"
|
|
94
250
|
onSubmit={event => {
|
|
95
251
|
event.preventDefault();
|
|
96
252
|
if (input.trim()) {
|
|
97
|
-
go(
|
|
253
|
+
go(FolderPickerUtil.typed(input, host));
|
|
98
254
|
}
|
|
99
255
|
}}
|
|
100
256
|
>
|
|
@@ -111,28 +267,41 @@ export function FolderPicker({ list, initial, onPick, onClose }: FolderPickerPro
|
|
|
111
267
|
</label>
|
|
112
268
|
</form>
|
|
113
269
|
<ul className="ta-folder-list">
|
|
114
|
-
{folders === undefined && !failed && (
|
|
270
|
+
{(folders === undefined || (loading && connecting)) && !failed && (
|
|
115
271
|
<li className="ta-folder-note">
|
|
116
272
|
<span className="ta-spinner" />
|
|
273
|
+
{connecting && <span className="ta-folder-step">{connecting}</span>}
|
|
117
274
|
</li>
|
|
118
275
|
)}
|
|
119
|
-
{
|
|
120
|
-
|
|
121
|
-
<
|
|
122
|
-
<
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
276
|
+
{!(loading && connecting) &&
|
|
277
|
+
folders?.map(folder => (
|
|
278
|
+
<li key={folder.name}>
|
|
279
|
+
<button type="button" className="ta-folder-item" onClick={() => go(FolderPickerUtil.child(path, folder.name))}>
|
|
280
|
+
<Icon icon={Folder} size={16} />
|
|
281
|
+
<span className="ta-folder-name">{folder.name}</span>
|
|
282
|
+
<Icon icon={ChevronRight} size={14} className="ta-folder-chevron" />
|
|
283
|
+
</button>
|
|
284
|
+
</li>
|
|
285
|
+
))}
|
|
286
|
+
{folders?.length === 0 && !connecting && <li className="ta-folder-note">{t('ta.folder.empty')}</li>}
|
|
287
|
+
{folders === undefined && failed && host && (
|
|
288
|
+
<li className="ta-folder-unreachable">
|
|
289
|
+
<strong>{t('ta.remote.failed', { host })}</strong>
|
|
290
|
+
<span>{failed}</span>
|
|
126
291
|
</li>
|
|
127
|
-
)
|
|
128
|
-
{folders?.length === 0 && <li className="ta-folder-note">{t('ta.folder.empty')}</li>}
|
|
292
|
+
)}
|
|
129
293
|
</ul>
|
|
130
|
-
{failed && <div className="ta-folder-failed">{
|
|
294
|
+
{failed && !(folders === undefined && host) && <div className="ta-folder-failed">{failed}</div>}
|
|
131
295
|
<div className="ta-dialog-actions">
|
|
132
296
|
<button type="button" className="ta-button" onClick={onClose}>
|
|
133
297
|
{t('ta.cancel')}
|
|
134
298
|
</button>
|
|
135
|
-
<button
|
|
299
|
+
<button
|
|
300
|
+
type="button"
|
|
301
|
+
className="ta-button ta-primary"
|
|
302
|
+
disabled={busy || loading || folders === undefined || TaLocation.host(path) !== host}
|
|
303
|
+
onClick={pick}
|
|
304
|
+
>
|
|
136
305
|
{t('ta.folder.open')}
|
|
137
306
|
</button>
|
|
138
307
|
</div>
|
|
@@ -142,29 +311,42 @@ export function FolderPicker({ list, initial, onPick, onClose }: FolderPickerPro
|
|
|
142
311
|
);
|
|
143
312
|
}
|
|
144
313
|
|
|
314
|
+
/** The name of a directory, with the host of one of another machine. */
|
|
315
|
+
export function DirectoryName({ cwd }: { cwd: string }): React.ReactElement {
|
|
316
|
+
const host = TaLocation.host(cwd);
|
|
317
|
+
return (
|
|
318
|
+
<>
|
|
319
|
+
<Icon icon={host ? Server : Folder} size={14} />
|
|
320
|
+
<span className="ta-ellipsis">{MessageUtil.basename(cwd) || cwd}</span>
|
|
321
|
+
{host && <span className="ta-host-tag">{host}</span>}
|
|
322
|
+
</>
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
145
326
|
export interface DirectoryButtonProps {
|
|
146
327
|
/** The directory now. */
|
|
147
328
|
cwd: string;
|
|
148
329
|
/** The entries of a directory of the server, the same function from one render to the next. */
|
|
149
330
|
list(directory: string): Promise<TaDirEntry[]>;
|
|
150
331
|
onPick(cwd: string): void | Promise<void>;
|
|
332
|
+
remote?: FolderPickerRemote;
|
|
151
333
|
}
|
|
152
334
|
|
|
153
335
|
/** The directory a new conversation will work in, beside the + of its message box: a click picks another. */
|
|
154
|
-
export function DirectoryButton({ cwd, list, onPick }: DirectoryButtonProps): React.ReactElement {
|
|
336
|
+
export function DirectoryButton({ cwd, list, onPick, remote }: DirectoryButtonProps): React.ReactElement {
|
|
155
337
|
const t = useT();
|
|
156
338
|
const [picking, setPicking] = React.useState(false);
|
|
157
339
|
return (
|
|
158
340
|
<>
|
|
159
341
|
<button type="button" className="ta-cwd-chip" title={t('ta.chat.cwdTitle', { path: cwd })} onClick={() => setPicking(true)}>
|
|
160
|
-
<
|
|
161
|
-
<span className="ta-ellipsis">{MessageUtil.basename(cwd) || cwd}</span>
|
|
342
|
+
<DirectoryName cwd={cwd} />
|
|
162
343
|
<Icon icon={ChevronDown} size={13} />
|
|
163
344
|
</button>
|
|
164
345
|
{picking && (
|
|
165
346
|
<FolderPicker
|
|
166
347
|
list={list}
|
|
167
348
|
initial={cwd}
|
|
349
|
+
remote={remote}
|
|
168
350
|
onClose={() => setPicking(false)}
|
|
169
351
|
onPick={async path => {
|
|
170
352
|
await onPick(path);
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { type TaForwardedPort, TaLocation, type TaRemoteHost } from '@celljs/ta-protocol';
|
|
2
|
+
import { Plug, Plus, Server, Trash2, Unplug, X } from 'lucide-react';
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { useNotices, useWorkbench } from '../hooks';
|
|
5
|
+
import { useT } from '../i18n/use-t';
|
|
6
|
+
import { RemoteHostsUtil } from './folder-picker';
|
|
7
|
+
import { Icon, IconButton } from './icon';
|
|
8
|
+
import { Row } from './settings-controls';
|
|
9
|
+
|
|
10
|
+
/** The hosts and forwarded ports of TaRemoteServer, read again when a host connects or disconnects. */
|
|
11
|
+
function useRemote() {
|
|
12
|
+
const [workbench, bench] = useWorkbench();
|
|
13
|
+
const [hosts, setHosts] = React.useState<TaRemoteHost[]>([]);
|
|
14
|
+
const [ports, setPorts] = React.useState<TaForwardedPort[]>([]);
|
|
15
|
+
const read = React.useCallback(async () => {
|
|
16
|
+
const [listed, forwarded] = await Promise.all([bench.remote.listHosts(), bench.remote.listForwardedPorts()]);
|
|
17
|
+
setHosts(listed);
|
|
18
|
+
setPorts(forwarded);
|
|
19
|
+
}, [bench]);
|
|
20
|
+
React.useEffect(() => {
|
|
21
|
+
if (workbench.status !== 'online') {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
read().catch(() => undefined);
|
|
25
|
+
return bench.onRemoteEvent(event => event.type === 'connection' && void read().catch(() => undefined));
|
|
26
|
+
}, [workbench.status, bench, read]);
|
|
27
|
+
return { bench, hosts, ports, read };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The machines conversations can work on (`ssh://` directories): those of ~/.ssh/config and those added,
|
|
32
|
+
* whether Ta is connected to each, and the ports forwarded from them. Connecting checks a host the way a
|
|
33
|
+
* conversation would: ssh signs in, Ta installs what it needs there.
|
|
34
|
+
*/
|
|
35
|
+
export function RemoteHosts() {
|
|
36
|
+
const t = useT();
|
|
37
|
+
const [, notices] = useNotices();
|
|
38
|
+
const { bench, hosts, ports, read } = useRemote();
|
|
39
|
+
const [text, setText] = React.useState('');
|
|
40
|
+
const [busy, setBusy] = React.useState<string>();
|
|
41
|
+
const run = async (host: string, action: () => Promise<unknown>) => {
|
|
42
|
+
setBusy(host);
|
|
43
|
+
try {
|
|
44
|
+
await action();
|
|
45
|
+
} catch (error) {
|
|
46
|
+
notices.error(error);
|
|
47
|
+
} finally {
|
|
48
|
+
setBusy(undefined);
|
|
49
|
+
await read().catch(() => undefined);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
const add = () => {
|
|
53
|
+
const read = TaLocation.readHost(text);
|
|
54
|
+
if ('error' in read) {
|
|
55
|
+
notices.notify(RemoteHostsUtil.problem(read, text, t), { level: 'warning' });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
setText('');
|
|
59
|
+
void run(read.host, () => bench.remote.addHost(read.host));
|
|
60
|
+
};
|
|
61
|
+
return (
|
|
62
|
+
<>
|
|
63
|
+
<Row label={t('ta.remote.add')} description={t('ta.remote.description')} stacked>
|
|
64
|
+
<form
|
|
65
|
+
className="ta-trusted-add"
|
|
66
|
+
onSubmit={event => {
|
|
67
|
+
event.preventDefault();
|
|
68
|
+
add();
|
|
69
|
+
}}
|
|
70
|
+
>
|
|
71
|
+
<input
|
|
72
|
+
className="ta-input"
|
|
73
|
+
value={text}
|
|
74
|
+
placeholder={t('ta.remote.addHint')}
|
|
75
|
+
aria-label={t('ta.remote.add')}
|
|
76
|
+
spellCheck={false}
|
|
77
|
+
onChange={event => setText(event.target.value)}
|
|
78
|
+
/>
|
|
79
|
+
<button type="submit" className="ta-button" disabled={!text.trim()}>
|
|
80
|
+
<Icon icon={Plus} size={14} /> {t('ta.remote.add')}
|
|
81
|
+
</button>
|
|
82
|
+
</form>
|
|
83
|
+
</Row>
|
|
84
|
+
{hosts.length === 0 && <Row label={t('ta.remote.none')} />}
|
|
85
|
+
{hosts.map(host => (
|
|
86
|
+
<Row
|
|
87
|
+
key={host.host}
|
|
88
|
+
label={host.host}
|
|
89
|
+
description={
|
|
90
|
+
<span className="ta-remote-state">
|
|
91
|
+
<span className={`ta-remote-light${host.connected ? ' ta-on' : ''}`} />
|
|
92
|
+
{[
|
|
93
|
+
t(host.connected ? 'ta.remote.connected' : 'ta.remote.disconnected'),
|
|
94
|
+
t(host.source === 'config' ? 'ta.remote.fromConfig' : 'ta.remote.added'),
|
|
95
|
+
].join(' · ')}
|
|
96
|
+
</span>
|
|
97
|
+
}
|
|
98
|
+
>
|
|
99
|
+
{host.connected ? (
|
|
100
|
+
<button
|
|
101
|
+
type="button"
|
|
102
|
+
className="ta-button"
|
|
103
|
+
disabled={busy === host.host}
|
|
104
|
+
onClick={() => run(host.host, () => bench.remote.disconnect(host.host))}
|
|
105
|
+
>
|
|
106
|
+
<Icon icon={Unplug} size={14} /> {t('ta.remote.disconnect')}
|
|
107
|
+
</button>
|
|
108
|
+
) : (
|
|
109
|
+
<button
|
|
110
|
+
type="button"
|
|
111
|
+
className="ta-button"
|
|
112
|
+
disabled={busy === host.host}
|
|
113
|
+
onClick={() =>
|
|
114
|
+
run(host.host, async () => {
|
|
115
|
+
await bench.remote.resolveLocation(TaLocation.ssh(host.host));
|
|
116
|
+
notices.notify(t('ta.remote.connectedTo', { host: host.host }), { level: 'success' });
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
>
|
|
120
|
+
{busy === host.host ? <span className="ta-spinner" /> : <Icon icon={Plug} size={14} />} {t('ta.remote.connect')}
|
|
121
|
+
</button>
|
|
122
|
+
)}
|
|
123
|
+
{host.source === 'recent' && (
|
|
124
|
+
<IconButton
|
|
125
|
+
icon={Trash2}
|
|
126
|
+
label={t('ta.remote.remove', { host: host.host })}
|
|
127
|
+
disabled={busy === host.host}
|
|
128
|
+
onClick={() => run(host.host, () => bench.remote.removeHost(host.host))}
|
|
129
|
+
/>
|
|
130
|
+
)}
|
|
131
|
+
</Row>
|
|
132
|
+
))}
|
|
133
|
+
{ports.map(port => (
|
|
134
|
+
<Row
|
|
135
|
+
key={`${port.host}:${port.remotePort}`}
|
|
136
|
+
label={t('ta.remote.port', { host: port.host, port: port.remotePort })}
|
|
137
|
+
description={`localhost:${port.localPort}`}
|
|
138
|
+
>
|
|
139
|
+
<Icon icon={Server} size={14} className="ta-muted" />
|
|
140
|
+
<IconButton
|
|
141
|
+
icon={X}
|
|
142
|
+
label={t('ta.remote.closePort')}
|
|
143
|
+
onClick={() => run(port.host, () => bench.remote.closeForwardedPort(port.host, port.remotePort))}
|
|
144
|
+
/>
|
|
145
|
+
</Row>
|
|
146
|
+
))}
|
|
147
|
+
</>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { TaRemoteEvent, TaRemotePrompt } from '@celljs/ta-protocol';
|
|
2
|
+
import { KeyRound, Server, ShieldQuestion } from 'lucide-react';
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { createPortal } from 'react-dom';
|
|
5
|
+
import { useNotices, useWorkbench } from '../hooks';
|
|
6
|
+
import { useT } from '../i18n/use-t';
|
|
7
|
+
import { Icon } from './icon';
|
|
8
|
+
|
|
9
|
+
/** Asks what ssh asks: a password or a passphrase (not shown), whether to trust a new host, or a line of text. */
|
|
10
|
+
function PromptDialog({ prompt, onAnswer }: { prompt: TaRemotePrompt; onAnswer(value: string | null): void }) {
|
|
11
|
+
const t = useT();
|
|
12
|
+
const [value, setValue] = React.useState('');
|
|
13
|
+
const confirm = prompt.kind === 'confirm';
|
|
14
|
+
return createPortal(
|
|
15
|
+
<div className="ta-modal-scrim">
|
|
16
|
+
<button type="button" className="ta-overlay-close" aria-label={t('ta.cancel')} tabIndex={-1} onClick={() => onAnswer(null)} />
|
|
17
|
+
<form
|
|
18
|
+
className="ta-modal ta-confirm ta-remote-prompt"
|
|
19
|
+
role="alertdialog"
|
|
20
|
+
aria-modal="true"
|
|
21
|
+
aria-label={t('ta.remote.prompt.title', { host: prompt.host })}
|
|
22
|
+
onKeyDown={event => event.key === 'Escape' && onAnswer(null)}
|
|
23
|
+
onSubmit={event => {
|
|
24
|
+
event.preventDefault();
|
|
25
|
+
onAnswer(confirm ? 'yes' : value);
|
|
26
|
+
}}
|
|
27
|
+
>
|
|
28
|
+
<div className="ta-confirm-title">
|
|
29
|
+
<Icon icon={confirm ? ShieldQuestion : prompt.kind === 'secret' ? KeyRound : Server} size={16} />
|
|
30
|
+
{t('ta.remote.prompt.title', { host: prompt.host })}
|
|
31
|
+
</div>
|
|
32
|
+
<pre className="ta-remote-message">{prompt.message}</pre>
|
|
33
|
+
{!confirm && (
|
|
34
|
+
<label className="ta-field">
|
|
35
|
+
<input
|
|
36
|
+
autoFocus
|
|
37
|
+
type={prompt.kind === 'secret' ? 'password' : 'text'}
|
|
38
|
+
autoComplete="off"
|
|
39
|
+
spellCheck={false}
|
|
40
|
+
value={value}
|
|
41
|
+
aria-label={prompt.message}
|
|
42
|
+
onChange={event => setValue(event.target.value)}
|
|
43
|
+
/>
|
|
44
|
+
</label>
|
|
45
|
+
)}
|
|
46
|
+
<div className="ta-dialog-actions">
|
|
47
|
+
<button type="button" className="ta-button" onClick={() => onAnswer(confirm ? 'no' : null)}>
|
|
48
|
+
{t('ta.cancel')}
|
|
49
|
+
</button>
|
|
50
|
+
<button type="submit" className="ta-button ta-primary" autoFocus={confirm}>
|
|
51
|
+
{t(confirm ? 'ta.remote.prompt.trust' : 'ta.remote.prompt.continue')}
|
|
52
|
+
</button>
|
|
53
|
+
</div>
|
|
54
|
+
</form>
|
|
55
|
+
</div>,
|
|
56
|
+
document.body,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* What ssh asks while ta serve connects to a host, one question at a time, in every page that is open (the
|
|
62
|
+
* first answer counts); a line at the bottom while a host connects, which may take a while the first
|
|
63
|
+
* time (it installs Node.js there); and a notice when a connection is lost.
|
|
64
|
+
*/
|
|
65
|
+
export function RemotePrompts() {
|
|
66
|
+
const t = useT();
|
|
67
|
+
const [, bench] = useWorkbench();
|
|
68
|
+
const [, notices] = useNotices();
|
|
69
|
+
const [prompts, setPrompts] = React.useState<TaRemotePrompt[]>([]);
|
|
70
|
+
const [steps, setSteps] = React.useState<Record<string, string>>({});
|
|
71
|
+
const connected = React.useRef(new Set<string>());
|
|
72
|
+
React.useEffect(
|
|
73
|
+
() =>
|
|
74
|
+
bench.onRemoteEvent((event: TaRemoteEvent) => {
|
|
75
|
+
if (event.type === 'prompt') {
|
|
76
|
+
setPrompts(shown => (shown.some(prompt => prompt.promptId === event.prompt.promptId) ? shown : [...shown, event.prompt]));
|
|
77
|
+
} else if (event.type === 'prompt_closed') {
|
|
78
|
+
setPrompts(shown => shown.filter(prompt => prompt.promptId !== event.promptId));
|
|
79
|
+
} else if (event.type === 'progress') {
|
|
80
|
+
setSteps(shown => ({ ...shown, [event.host]: t(`ta.remote.step.${event.step}`, { host: event.host, detail: event.detail ?? '' }) }));
|
|
81
|
+
} else {
|
|
82
|
+
setSteps(({ [event.host]: _gone, ...rest }) => rest);
|
|
83
|
+
// Lost, not closed on purpose; a connection that failed is the caller's to tell.
|
|
84
|
+
if (!event.connected && event.error && connected.current.has(event.host)) {
|
|
85
|
+
notices.notify(t('ta.remote.lost', { host: event.host }), { level: 'warning', detail: event.error });
|
|
86
|
+
}
|
|
87
|
+
connected.current[event.connected ? 'add' : 'delete'](event.host);
|
|
88
|
+
}
|
|
89
|
+
}),
|
|
90
|
+
[bench, notices, t],
|
|
91
|
+
);
|
|
92
|
+
const [prompt] = prompts;
|
|
93
|
+
const answer = (value: string | null) => {
|
|
94
|
+
setPrompts(shown => shown.slice(1));
|
|
95
|
+
void bench.remote.answer(prompt.promptId, value).catch(() => undefined);
|
|
96
|
+
};
|
|
97
|
+
const lines = Object.values(steps);
|
|
98
|
+
return (
|
|
99
|
+
<>
|
|
100
|
+
{prompt && <PromptDialog key={prompt.promptId} prompt={prompt} onAnswer={answer} />}
|
|
101
|
+
{lines.length > 0 &&
|
|
102
|
+
createPortal(
|
|
103
|
+
<div className="ta-remote-status" role="status">
|
|
104
|
+
<span className="ta-spinner" />
|
|
105
|
+
{lines.join(' · ')}
|
|
106
|
+
</div>,
|
|
107
|
+
document.body,
|
|
108
|
+
)}
|
|
109
|
+
</>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
Puzzle,
|
|
20
20
|
RefreshCw,
|
|
21
21
|
Search,
|
|
22
|
+
Server,
|
|
22
23
|
Smartphone,
|
|
23
24
|
Sparkles,
|
|
24
25
|
Sun,
|
|
@@ -37,6 +38,7 @@ import { AgentControl, ScopeBar, useAgentSettings } from './agent-settings-view'
|
|
|
37
38
|
import { AuthDialog } from './auth-view';
|
|
38
39
|
import { Icon, IconButton } from './icon';
|
|
39
40
|
import { RelayDialog } from './relay-view';
|
|
41
|
+
import { RemoteHosts } from './remote-hosts';
|
|
40
42
|
import { type Choice, Keys, Row, type RowEntry, row, Segmented, Switch } from './settings-controls';
|
|
41
43
|
import { TrustedDomains } from './trusted-domains';
|
|
42
44
|
import { WorkingDirectoryPicker } from './working-directory';
|
|
@@ -336,6 +338,12 @@ function useParts(scope: TaSettingsScope, onScope: (scope: TaSettingsScope) => v
|
|
|
336
338
|
),
|
|
337
339
|
],
|
|
338
340
|
},
|
|
341
|
+
{
|
|
342
|
+
id: 'remote',
|
|
343
|
+
title: t('ta.remote.title'),
|
|
344
|
+
icon: Server,
|
|
345
|
+
rows: [row('remote', [t('ta.remote.title'), t('ta.remote.description'), 'ssh'], <RemoteHosts />)],
|
|
346
|
+
},
|
|
339
347
|
{
|
|
340
348
|
id: 'shortcuts',
|
|
341
349
|
title: t('ta.settings.shortcuts'),
|