agents-city 0.3.0 → 0.4.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/.claude-plugin/marketplace.json +1 -1
- package/README.es.md +54 -5
- package/README.md +53 -5
- package/bin/agents-city.js +4 -0
- package/bin/hall.html +92 -5
- package/bin/navegador.mjs +141 -12
- package/bin/serve.py +94 -38
- package/bin/test +6 -3
- package/bin/test-demo.py +67 -0
- package/bin/test-desinstala.py +170 -0
- package/bin/test-navegador.py +2 -0
- package/bin/test-serve.py +122 -2
- package/bin/uninstall +5 -0
- package/city/web/dist/city.js +65 -65
- package/city/web/dist/index.html +18 -5
- package/city/web/dist-hall/hall.js +941 -385
- package/city/web/index.html +17 -4
- package/city/web/src/bienvenida.ts +35 -329
- package/city/web/src/casa.ts +282 -0
- package/city/web/src/demo.ts +247 -0
- package/city/web/src/dialogo.ts +178 -0
- package/city/web/src/es.ts +96 -0
- package/city/web/src/explorador.ts +189 -0
- package/city/web/src/hall.ts +157 -54
- package/city/web/src/main.ts +28 -5
- package/demo/graba.py +132 -0
- package/demo/grabaciones/legal.jsonl +22 -0
- package/demo/grabaciones/medicina.jsonl +22 -0
- package/demo/grabaciones/software.jsonl +22 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/scripts/demos.py +107 -0
- package/plugin/scripts/desinstala.py +222 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app's own way of asking a question.
|
|
3
|
+
*
|
|
4
|
+
* Everything here used to be `window.prompt` and `window.confirm`. Those are
|
|
5
|
+
* fine for a debug build and wrong for a product: they are unstyled, they cannot
|
|
6
|
+
* hold two fields, they cannot explain what a destructive answer costs, they
|
|
7
|
+
* ignore the page's language and theme entirely, and on some browsers a page can
|
|
8
|
+
* be told to suppress them — at which point a button silently does nothing. A
|
|
9
|
+
* product that has designed its own onboarding and then asks for a city name
|
|
10
|
+
* through a grey system box is telling on itself.
|
|
11
|
+
*
|
|
12
|
+
* One dialog, promise-shaped: it resolves with the answers, or with `null` when
|
|
13
|
+
* the person backs out. Escape and the backdrop cancel; Enter accepts from any
|
|
14
|
+
* single-line field. Focus moves in on open and back out on close.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface Campo {
|
|
18
|
+
/** Key this field's value appears under in the answer. */
|
|
19
|
+
id: string;
|
|
20
|
+
etiqueta: string;
|
|
21
|
+
valor?: string;
|
|
22
|
+
pista?: string;
|
|
23
|
+
/** A `<select>` instead of an input, when the answer is one of a few things. */
|
|
24
|
+
opciones?: Array<{ valor: string; texto: string }>;
|
|
25
|
+
requerido?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface Peticion {
|
|
29
|
+
titulo: string;
|
|
30
|
+
/** Plain sentences under the title. Rendered as text, never as markup. */
|
|
31
|
+
cuerpo?: string[];
|
|
32
|
+
campos?: Campo[];
|
|
33
|
+
aceptar?: string;
|
|
34
|
+
cancelar?: string;
|
|
35
|
+
/** Red confirm button, for anything that loses work. */
|
|
36
|
+
peligro?: boolean;
|
|
37
|
+
/** Only enable the confirm button once this exact string is typed in the
|
|
38
|
+
* first field: the guard a destructive action deserves. */
|
|
39
|
+
exige?: string;
|
|
40
|
+
/** Extra markup owned by the caller — the house form goes here. */
|
|
41
|
+
contenido?: string;
|
|
42
|
+
/** Wire that markup once it is in the DOM. `cierra` accepts on its behalf. */
|
|
43
|
+
enlaza?: (raiz: HTMLElement, cierra: (respuesta: Record<string, string> | null) => void) => void;
|
|
44
|
+
ancho?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function esc(s: unknown): string {
|
|
48
|
+
return String(s).replace(
|
|
49
|
+
/[&<>"']/g,
|
|
50
|
+
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] as string,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let abierto: HTMLElement | null = null;
|
|
55
|
+
|
|
56
|
+
/** Ask. Resolves with the answers keyed by field id, or null if cancelled. */
|
|
57
|
+
export function pregunta(p: Peticion): Promise<Record<string, string> | null> {
|
|
58
|
+
// One at a time. A second dialog over the first would leave the first's
|
|
59
|
+
// promise hanging for the life of the page.
|
|
60
|
+
abierto?.remove();
|
|
61
|
+
|
|
62
|
+
const campos = p.campos ?? [];
|
|
63
|
+
const fondo = document.createElement('div');
|
|
64
|
+
fondo.className = 'dlgFondo';
|
|
65
|
+
fondo.innerHTML = `
|
|
66
|
+
<div class="dlg" role="dialog" aria-modal="true" aria-label="${esc(p.titulo)}"
|
|
67
|
+
style="${p.ancho ? `max-width:${p.ancho}px` : ''}">
|
|
68
|
+
<h2>${esc(p.titulo)}</h2>
|
|
69
|
+
${(p.cuerpo ?? []).map((l) => `<p class="dlgProsa">${esc(l)}</p>`).join('')}
|
|
70
|
+
${campos
|
|
71
|
+
.map(
|
|
72
|
+
(c) => `<div class="campo"><label for="dlg-${esc(c.id)}">${esc(c.etiqueta)}</label>
|
|
73
|
+
${
|
|
74
|
+
c.opciones
|
|
75
|
+
? `<select id="dlg-${esc(c.id)}" data-campo="${esc(c.id)}">${c.opciones
|
|
76
|
+
.map(
|
|
77
|
+
(o) =>
|
|
78
|
+
`<option value="${esc(o.valor)}"${
|
|
79
|
+
o.valor === (c.valor ?? '') ? ' selected' : ''
|
|
80
|
+
}>${esc(o.texto)}</option>`,
|
|
81
|
+
)
|
|
82
|
+
.join('')}</select>`
|
|
83
|
+
: `<input id="dlg-${esc(c.id)}" data-campo="${esc(c.id)}" type="text"
|
|
84
|
+
value="${esc(c.valor ?? '')}" placeholder="${esc(c.pista ?? '')}"
|
|
85
|
+
autocomplete="off" spellcheck="false">`
|
|
86
|
+
}</div>`,
|
|
87
|
+
)
|
|
88
|
+
.join('')}
|
|
89
|
+
${p.contenido ?? ''}
|
|
90
|
+
<div class="dlgBotones">
|
|
91
|
+
<button type="button" class="bt" data-dlg="no">${esc(p.cancelar ?? 'Cancel')}</button>
|
|
92
|
+
<button type="button" class="bt ${p.peligro ? 'malo' : 'ppal'}" data-dlg="si">${esc(
|
|
93
|
+
p.aceptar ?? 'OK',
|
|
94
|
+
)}</button>
|
|
95
|
+
</div>
|
|
96
|
+
</div>`;
|
|
97
|
+
document.body.appendChild(fondo);
|
|
98
|
+
abierto = fondo;
|
|
99
|
+
|
|
100
|
+
const devolvia = document.activeElement as HTMLElement | null;
|
|
101
|
+
const caja = fondo.querySelector<HTMLElement>('.dlg') as HTMLElement;
|
|
102
|
+
const si = fondo.querySelector<HTMLButtonElement>('[data-dlg="si"]') as HTMLButtonElement;
|
|
103
|
+
|
|
104
|
+
return new Promise((resuelve) => {
|
|
105
|
+
let cerrado = false;
|
|
106
|
+
const cierra = (respuesta: Record<string, string> | null): void => {
|
|
107
|
+
if (cerrado) return;
|
|
108
|
+
cerrado = true;
|
|
109
|
+
document.removeEventListener('keydown', tecla, true);
|
|
110
|
+
fondo.remove();
|
|
111
|
+
if (abierto === fondo) abierto = null;
|
|
112
|
+
devolvia?.focus?.();
|
|
113
|
+
resuelve(respuesta);
|
|
114
|
+
};
|
|
115
|
+
const lee = (): Record<string, string> => {
|
|
116
|
+
const fuera: Record<string, string> = {};
|
|
117
|
+
fondo.querySelectorAll<HTMLInputElement | HTMLSelectElement>('[data-campo]').forEach((el) => {
|
|
118
|
+
fuera[el.dataset.campo ?? ''] = el.value;
|
|
119
|
+
});
|
|
120
|
+
return fuera;
|
|
121
|
+
};
|
|
122
|
+
const acepta = (): void => {
|
|
123
|
+
const valores = lee();
|
|
124
|
+
if (campos.some((c) => c.requerido && !valores[c.id]?.trim())) return;
|
|
125
|
+
cierra(valores);
|
|
126
|
+
};
|
|
127
|
+
const revisa = (): void => {
|
|
128
|
+
const valores = lee();
|
|
129
|
+
const falta = campos.some((c) => c.requerido && !valores[c.id]?.trim());
|
|
130
|
+
const mal = p.exige !== undefined && (valores[campos[0]?.id ?? ''] ?? '').trim() !== p.exige;
|
|
131
|
+
si.disabled = falta || mal;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
si.onclick = acepta;
|
|
135
|
+
(fondo.querySelector('[data-dlg="no"]') as HTMLButtonElement).onclick = () => cierra(null);
|
|
136
|
+
fondo.onclick = (e) => {
|
|
137
|
+
if (e.target === fondo) cierra(null);
|
|
138
|
+
};
|
|
139
|
+
caja.addEventListener('input', revisa);
|
|
140
|
+
caja.addEventListener('change', revisa);
|
|
141
|
+
fondo.querySelectorAll<HTMLInputElement>('input').forEach((el) => {
|
|
142
|
+
el.onkeydown = (e) => {
|
|
143
|
+
if (e.key === 'Enter' && !si.disabled) {
|
|
144
|
+
e.preventDefault();
|
|
145
|
+
acepta();
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
});
|
|
149
|
+
const tecla = (e: KeyboardEvent): void => {
|
|
150
|
+
if (e.key === 'Escape') {
|
|
151
|
+
e.preventDefault();
|
|
152
|
+
cierra(null);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
document.addEventListener('keydown', tecla, true);
|
|
156
|
+
|
|
157
|
+
p.enlaza?.(caja, cierra);
|
|
158
|
+
revisa();
|
|
159
|
+
(
|
|
160
|
+
caja.querySelector<HTMLElement>('input,select,textarea') ?? (si.disabled ? caja : si)
|
|
161
|
+
).focus?.();
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Yes or no, with no fields. Resolves true only on an explicit yes. */
|
|
166
|
+
export async function confirma(
|
|
167
|
+
titulo: string,
|
|
168
|
+
cuerpo: string[] = [],
|
|
169
|
+
opciones: { aceptar?: string; peligro?: boolean } = {},
|
|
170
|
+
): Promise<boolean> {
|
|
171
|
+
const r = await pregunta({
|
|
172
|
+
titulo,
|
|
173
|
+
cuerpo,
|
|
174
|
+
aceptar: opciones.aceptar ?? 'Yes',
|
|
175
|
+
peligro: opciones.peligro,
|
|
176
|
+
});
|
|
177
|
+
return r !== null;
|
|
178
|
+
}
|
package/city/web/src/es.ts
CHANGED
|
@@ -178,3 +178,99 @@ anota({
|
|
|
178
178
|
'urgencias, api, the handbook — whatever you would say out loud':
|
|
179
179
|
'urgencias, api, el manual — como lo llamarías en voz alta',
|
|
180
180
|
});
|
|
181
|
+
|
|
182
|
+
anota({
|
|
183
|
+
// ── the app's own dialogs ─────────────────────────────────────────────────
|
|
184
|
+
Yes: 'Sí',
|
|
185
|
+
'What is it called?': '¿Cómo se llama?',
|
|
186
|
+
'home, clients, the lab — whatever you would say out loud':
|
|
187
|
+
'casa, clientes, el laboratorio — como lo dirías en voz alta',
|
|
188
|
+
'A city is a place with its own seat, its own houses and its own map. Yours stay where they are.':
|
|
189
|
+
'Una ciudad es un sitio con su propio asiento, sus casas y su mapa. Las que ya tienes se quedan donde están.',
|
|
190
|
+
'Start over {city}?': '¿Empezar {city} de cero?',
|
|
191
|
+
'Start over': 'Empezar de cero',
|
|
192
|
+
'You lose:': 'Pierdes:',
|
|
193
|
+
'You keep:': 'Conservas:',
|
|
194
|
+
'Right now that is {agents} house(s), {roads} road(s) and {acts} committee act(s).':
|
|
195
|
+
'Ahora mismo eso son {agents} casa(s), {roads} carretera(s) y {acts} acta(s) de comité.',
|
|
196
|
+
'Type the city’s name to confirm': 'Escribe el nombre de la ciudad para confirmar',
|
|
197
|
+
'Archive {city}?': '¿Archivar {city}?',
|
|
198
|
+
'Archive it': 'Archivarla',
|
|
199
|
+
'It moves into .backups. Nothing is deleted, and you can put it back by hand.':
|
|
200
|
+
'Se mueve a .backups. No se borra nada, y puedes devolverla a mano.',
|
|
201
|
+
'Which engine?': '¿Qué motor?',
|
|
202
|
+
'Model alias': 'Alias del modelo',
|
|
203
|
+
'An alias the CLI resolves when the window opens. Anything it accepts works here.':
|
|
204
|
+
'Un alias que la CLI resuelve al abrir la ventana. Vale cualquiera que ella acepte.',
|
|
205
|
+
'Use it': 'Usarlo',
|
|
206
|
+
'Remove the skill {skill}?': '¿Quitar la skill {skill}?',
|
|
207
|
+
'Remove it': 'Quitarla',
|
|
208
|
+
'It is deleted from this agent’s own home. Nothing outside that folder is touched.':
|
|
209
|
+
'Se borra de la casa de este agente. Nada fuera de esa carpeta se toca.',
|
|
210
|
+
'Build a house': 'Construir una casa',
|
|
211
|
+
'One worker, its own window, its own corner of your disk. Everything here can be changed afterwards.':
|
|
212
|
+
'Un trabajador, su ventana y su rincón de tu disco. Todo esto se puede cambiar después.',
|
|
213
|
+
'{name} has a house now': '{name} ya tiene casa',
|
|
214
|
+
'{name} joined the city': '{name} se ha mudado a la ciudad',
|
|
215
|
+
'What else does it work on?': '¿Sobre qué más trabaja?',
|
|
216
|
+
'A repository, a worktree, or a folder of documents. It is linked, never copied.':
|
|
217
|
+
'Un repositorio, un worktree o una carpeta de documentos. Se enlaza, nunca se copia.',
|
|
218
|
+
'Mount it': 'Montarlo',
|
|
219
|
+
Mounted: 'Montado',
|
|
220
|
+
'Stop this agent working on {what}?': '¿Que este agente deje de trabajar sobre {what}?',
|
|
221
|
+
'Unmount it': 'Desmontarlo',
|
|
222
|
+
'The link goes. The folder it points at stays exactly where it is.':
|
|
223
|
+
'Se va el enlace. La carpeta a la que apunta se queda exactamente donde está.',
|
|
224
|
+
|
|
225
|
+
// ── what the form says when something is missing ──────────────────────────
|
|
226
|
+
'Give it a name — it is how you will call it in its window':
|
|
227
|
+
'Ponle un nombre — es como la llamarás en su ventana',
|
|
228
|
+
'Could not add that agent': 'No se pudo añadir ese agente',
|
|
229
|
+
'could not mount': 'no se pudo montar',
|
|
230
|
+
'Could not set its engine': 'No se pudo fijar su motor',
|
|
231
|
+
'{n} mount': '{n} montaje',
|
|
232
|
+
'{n} mounts': '{n} montajes',
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
anota({
|
|
236
|
+
// ── walking the disk ──────────────────────────────────────────────────────
|
|
237
|
+
'Walk your disk and pick whatever you like: a repository, a worktree, a folder of documents, one exact file. As many as you want. Nothing is copied — each one is linked into this agent’s own home.':
|
|
238
|
+
'Recorre tu disco y coge lo que quieras: un repositorio, un worktree, una carpeta de documentos, un fichero exacto. Tantos como quieras. No se copia nada — cada uno se enlaza dentro de la casa de este agente.',
|
|
239
|
+
'Working on': 'Trabaja sobre',
|
|
240
|
+
'Nothing chosen yet — an agent with no mounts is fine too.':
|
|
241
|
+
'Aún no has cogido nada — un agente sin montajes también vale.',
|
|
242
|
+
'Add this folder': 'Añadir esta carpeta',
|
|
243
|
+
'Add this': 'Añadir esto',
|
|
244
|
+
'Already chosen': 'Ya está cogido',
|
|
245
|
+
'Up one folder': 'Subir una carpeta',
|
|
246
|
+
'reading that folder': 'leyendo esa carpeta',
|
|
247
|
+
'This folder is empty.': 'Esta carpeta está vacía.',
|
|
248
|
+
'Only the first 2000 shown.': 'Sólo se muestran los 2000 primeros.',
|
|
249
|
+
'A repository, a worktree, a folder of documents, one exact file. It is linked, never copied.':
|
|
250
|
+
'Un repositorio, un worktree, una carpeta de documentos, un fichero exacto. Se enlaza, nunca se copia.',
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
anota({
|
|
254
|
+
// ── the demo shelf ────────────────────────────────────────────────────────
|
|
255
|
+
Demos: 'Demos',
|
|
256
|
+
demos: 'demos',
|
|
257
|
+
'See a committee happen': 'Mira un comité de verdad',
|
|
258
|
+
'reading the demo shelf': 'leyendo la estantería de demos',
|
|
259
|
+
'A real question, answered by a city of agents that never talk to each other behind the chair’s back. Pick the field closest to yours — the machine is the same one in all three; only the work changes.':
|
|
260
|
+
'Una pregunta de verdad, respondida por una ciudad de agentes que nunca hablan entre ellos a espaldas de quien preside. Elige el campo más cercano al tuyo — la maquinaria es la misma en los tres; lo que cambia es el trabajo.',
|
|
261
|
+
'{n} turns': '{n} turnos',
|
|
262
|
+
'These are recordings of real runs over the real bus, played back here. To run one live in a terminal: agents-city demo --domain software.':
|
|
263
|
+
'Son grabaciones de ejecuciones reales sobre el bus real, reproducidas aquí. Para lanzar una en vivo desde terminal: agents-city demo --domain software.',
|
|
264
|
+
'All demos': 'Todas las demos',
|
|
265
|
+
Play: 'Reproducir',
|
|
266
|
+
Pause: 'Pausa',
|
|
267
|
+
Replay: 'Repetir',
|
|
268
|
+
'{done} of {total}': '{done} de {total}',
|
|
269
|
+
'Press play. The turns arrive one by one, exactly as they did.':
|
|
270
|
+
'Dale a reproducir. Los turnos van llegando uno a uno, tal cual llegaron.',
|
|
271
|
+
'A recording of a real run: these events came off the real bus, from the real committee. Nothing here is being decided now.':
|
|
272
|
+
'Una grabación de una ejecución real: estos eventos salieron del bus real, del comité real. Aquí no se está decidiendo nada ahora mismo.',
|
|
273
|
+
'Nothing recorded here': 'Aquí no hay nada grabado',
|
|
274
|
+
'This install has no demo recordings. Make them with demo/graba.py, or run the full thing from a terminal with agents-city demo.':
|
|
275
|
+
'Esta instalación no tiene grabaciones. Créalas con demo/graba.py, o lanza la cosa entera desde terminal con agents-city demo.',
|
|
276
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A folder picker. You walk, you choose.
|
|
3
|
+
*
|
|
4
|
+
* What was here before scanned the whole disk and offered what it had decided
|
|
5
|
+
* you probably wanted: your repositories, ranked. That is the wrong shape for
|
|
6
|
+
* this question. A person choosing what an agent works on already knows where
|
|
7
|
+
* their work is; being handed a guessed list of two hundred things is not help,
|
|
8
|
+
* it is a second list to read before you can do what you came to do. And it
|
|
9
|
+
* could only ever offer what it knew how to look for — a repository, a folder
|
|
10
|
+
* of markdown — never *that* file, in *that* folder, which is a perfectly
|
|
11
|
+
* ordinary thing to want.
|
|
12
|
+
*
|
|
13
|
+
* So: a folder, its contents, and a way up and down. Nothing is offered in
|
|
14
|
+
* advance and nothing is filtered out. Folders are marked `git` or `wt` when
|
|
15
|
+
* they are, because that is worth knowing — never because it changes what you
|
|
16
|
+
* are allowed to pick.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { t as _ } from './idioma';
|
|
20
|
+
|
|
21
|
+
export interface Entrada {
|
|
22
|
+
nombre: string;
|
|
23
|
+
ruta: string;
|
|
24
|
+
dir: boolean;
|
|
25
|
+
/** `repo` · `worktree` · `` — a label, never a filter. */
|
|
26
|
+
git: string;
|
|
27
|
+
enlace: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface Listado {
|
|
31
|
+
ruta: string;
|
|
32
|
+
arriba: string;
|
|
33
|
+
casa: string;
|
|
34
|
+
atajos: Array<{ nombre: string; ruta: string }>;
|
|
35
|
+
entradas: Entrada[];
|
|
36
|
+
recortada: boolean;
|
|
37
|
+
error?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface PuertaExp {
|
|
41
|
+
api: <T>(ruta: string, opts?: RequestInit) => Promise<T>;
|
|
42
|
+
esc: (s: unknown) => string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const MARCA: Record<string, string> = { repo: 'git', worktree: 'wt' };
|
|
46
|
+
|
|
47
|
+
export class Explorador {
|
|
48
|
+
private aqui = '';
|
|
49
|
+
private listado: Listado | null = null;
|
|
50
|
+
private cargando = false;
|
|
51
|
+
private fallo = '';
|
|
52
|
+
private host: HTMLElement | null = null;
|
|
53
|
+
|
|
54
|
+
constructor(
|
|
55
|
+
private readonly p: PuertaExp,
|
|
56
|
+
/** Called with a path when a row's + is pressed. */
|
|
57
|
+
private readonly elige: (ruta: string, esCarpeta: boolean) => void,
|
|
58
|
+
/** Which paths are already chosen, so their rows read as chosen. */
|
|
59
|
+
private readonly yaElegidas: () => string[],
|
|
60
|
+
) {}
|
|
61
|
+
|
|
62
|
+
/** Render into `host` and wire it. Safe to call again: that is the repaint. */
|
|
63
|
+
monta(host: HTMLElement): void {
|
|
64
|
+
this.host = host;
|
|
65
|
+
if (!this.listado && !this.cargando) void this.ve(this.aqui || '~');
|
|
66
|
+
host.innerHTML = this.html();
|
|
67
|
+
this.enlaza(host);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private repinta(): void {
|
|
71
|
+
if (this.host) {
|
|
72
|
+
this.host.innerHTML = this.html();
|
|
73
|
+
this.enlaza(this.host);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Walk to a folder. An unreadable one is said out loud, not swallowed. */
|
|
78
|
+
async ve(ruta: string): Promise<void> {
|
|
79
|
+
this.cargando = true;
|
|
80
|
+
this.fallo = '';
|
|
81
|
+
this.repinta();
|
|
82
|
+
try {
|
|
83
|
+
const r = await this.p.api<Listado>('/api/carpeta?path=' + encodeURIComponent(ruta));
|
|
84
|
+
if (r.error) this.fallo = r.error;
|
|
85
|
+
else {
|
|
86
|
+
this.listado = r;
|
|
87
|
+
this.aqui = r.ruta;
|
|
88
|
+
}
|
|
89
|
+
} catch (e) {
|
|
90
|
+
this.fallo = String(e);
|
|
91
|
+
} finally {
|
|
92
|
+
this.cargando = false;
|
|
93
|
+
this.repinta();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The path being looked at right now — what "add this folder" means. */
|
|
98
|
+
get carpeta(): string {
|
|
99
|
+
return this.aqui;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private migas(): string {
|
|
103
|
+
const esc = this.p.esc;
|
|
104
|
+
const l = this.listado;
|
|
105
|
+
if (!l) return '';
|
|
106
|
+
const partes = l.ruta.split('/').filter(Boolean);
|
|
107
|
+
let acumulado = '';
|
|
108
|
+
const trozos = partes.map((parte) => {
|
|
109
|
+
acumulado += '/' + parte;
|
|
110
|
+
return `<button type="button" class="expMiga" data-exp="ve" data-ruta="${esc(
|
|
111
|
+
acumulado,
|
|
112
|
+
)}">${esc(parte)}</button>`;
|
|
113
|
+
});
|
|
114
|
+
return (
|
|
115
|
+
`<button type="button" class="expMiga" data-exp="ve" data-ruta="/">/</button>` +
|
|
116
|
+
trozos.join('<i>/</i>')
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
html(): string {
|
|
121
|
+
const esc = this.p.esc;
|
|
122
|
+
const l = this.listado;
|
|
123
|
+
const elegidas = new Set(this.yaElegidas());
|
|
124
|
+
const filas = (l?.entradas ?? [])
|
|
125
|
+
.map((e) => {
|
|
126
|
+
const puesta = elegidas.has(e.ruta);
|
|
127
|
+
return `<div class="expFila ${e.dir ? 'dir' : 'fich'} ${puesta ? 'puesta' : ''}">
|
|
128
|
+
<button type="button" class="expNombre" ${
|
|
129
|
+
e.dir ? `data-exp="ve" data-ruta="${esc(e.ruta)}"` : 'disabled'
|
|
130
|
+
} title="${esc(e.ruta)}">
|
|
131
|
+
<i class="expIcono">${e.dir ? '▸' : '·'}</i>${esc(e.nombre)}${
|
|
132
|
+
e.git ? `<em class="expMarca">${esc(MARCA[e.git] ?? e.git)}</em>` : ''
|
|
133
|
+
}${e.enlace ? '<em class="expMarca">link</em>' : ''}</button>
|
|
134
|
+
<button type="button" class="expMas" data-exp="toma" data-ruta="${esc(e.ruta)}"
|
|
135
|
+
data-dir="${e.dir ? '1' : ''}"
|
|
136
|
+
title="${esc(puesta ? _('Already chosen') : _('Add this'))}">${puesta ? '✓' : '+'}</button>
|
|
137
|
+
</div>`;
|
|
138
|
+
})
|
|
139
|
+
.join('');
|
|
140
|
+
const atajos = (l?.atajos ?? [])
|
|
141
|
+
.map(
|
|
142
|
+
(a) =>
|
|
143
|
+
`<button type="button" class="expAtajo ${
|
|
144
|
+
a.ruta === l?.ruta ? 'aqui' : ''
|
|
145
|
+
}" data-exp="ve" data-ruta="${esc(a.ruta)}">${esc(a.nombre)}</button>`,
|
|
146
|
+
)
|
|
147
|
+
.join('');
|
|
148
|
+
return `
|
|
149
|
+
<div class="exp">
|
|
150
|
+
<div class="expAtajos">${atajos}</div>
|
|
151
|
+
<div class="expBarra">
|
|
152
|
+
<button type="button" class="expSubir" data-exp="ve"
|
|
153
|
+
data-ruta="${esc(l?.arriba ?? '~')}" ${l?.arriba ? '' : 'disabled'}
|
|
154
|
+
title="${_('Up one folder')}">↑</button>
|
|
155
|
+
<div class="expMigas">${this.migas()}</div>
|
|
156
|
+
</div>
|
|
157
|
+
${
|
|
158
|
+
this.fallo
|
|
159
|
+
? `<p class="pista malo">${esc(this.fallo)}</p>`
|
|
160
|
+
: this.cargando && !l
|
|
161
|
+
? `<p class="cargando">${_('reading that folder')}</p>`
|
|
162
|
+
: filas
|
|
163
|
+
? `<div class="expLista">${filas}</div>`
|
|
164
|
+
: `<p class="pista">${_('This folder is empty.')}</p>`
|
|
165
|
+
}
|
|
166
|
+
${l?.recortada ? `<p class="pista">${_('Only the first 2000 shown.')}</p>` : ''}
|
|
167
|
+
<button type="button" class="bt bvMini expTomaAqui" data-exp="toma"
|
|
168
|
+
data-ruta="${esc(l?.ruta ?? '')}" data-dir="1" ${l ? '' : 'disabled'}>${_(
|
|
169
|
+
'Add this folder',
|
|
170
|
+
)}</button>
|
|
171
|
+
</div>`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private enlaza(raiz: HTMLElement): void {
|
|
175
|
+
raiz.querySelectorAll<HTMLElement>('[data-exp]').forEach((el) => {
|
|
176
|
+
el.onclick = (evento) => {
|
|
177
|
+
evento.preventDefault();
|
|
178
|
+
evento.stopPropagation();
|
|
179
|
+
const ruta = el.dataset.ruta ?? '';
|
|
180
|
+
if (!ruta) return;
|
|
181
|
+
if (el.dataset.exp === 've') void this.ve(ruta);
|
|
182
|
+
else {
|
|
183
|
+
this.elige(ruta, el.dataset.dir === '1');
|
|
184
|
+
this.repinta();
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|