@danieltmn/openbridge 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +146 -144
- package/package.json +1 -1
- package/src/bridge/bridge.js +77 -1
- package/src/cli.js +49 -2
- package/src/web/assets/app.js +220 -1
- package/src/web/routes.js +3 -0
- package/src/web/templates/chat.html +42 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,16 @@ Todos los cambios relevantes de OpenBridge. Formato basado en
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/) y
|
|
5
5
|
[Versionado Semantico](https://semver.org/lang/es/).
|
|
6
6
|
|
|
7
|
+
## [0.3.0] - 2026-09-13
|
|
8
|
+
|
|
9
|
+
### Cambios
|
|
10
|
+
|
|
11
|
+
- web: revertir un archivo puntual desde la vista cambios
|
|
12
|
+
- cli: comando tunnel (estado/cambio de proveedor y dominio fijo)
|
|
13
|
+
- web: plantillas de prompts y boton de revertir cambios
|
|
14
|
+
- web: vista de cambios del proyecto (git status/diff) via el puente
|
|
15
|
+
- release: recupera flags que npm se queda (--yes, --tag, --dry-run, --preid)
|
|
16
|
+
|
|
7
17
|
## [0.2.0] - 2026-09-13
|
|
8
18
|
|
|
9
19
|
### Cambios
|
package/README.md
CHANGED
|
@@ -1,144 +1,146 @@
|
|
|
1
|
-
# OpenBridge
|
|
2
|
-
|
|
3
|
-
[](https://www.npmjs.com/package/@danieltmn/openbridge)
|
|
4
|
-
[](https://github.com/tamnora/openbridge/actions/workflows/ci.yml)
|
|
5
|
-
[](LICENSE)
|
|
6
|
-
|
|
7
|
-
Tu **opencode** en el celular, **sin hosting**: corre la app, el puente y un
|
|
8
|
-
túnel público desde tu PC. El celular entra por la URL del túnel y vos manejás
|
|
9
|
-
opencode (tus carpetas, modelos y chats) desde donde estés.
|
|
10
|
-
|
|
11
|
-
> Proyecto nuevo e independiente, portado a **Node** desde OpenConex (PHP).
|
|
12
|
-
> Ya no hace falta subir nada a un hosting ni depender de uno.
|
|
13
|
-
|
|
14
|
-
## Cómo funciona
|
|
15
|
-
|
|
16
|
-
```
|
|
17
|
-
[Celular] ──► URL del túnel ──► App Node en tu PC ──► .openbridge/data/ (JSON)
|
|
18
|
-
│
|
|
19
|
-
└──► bridge (Node) ──► opencode run
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
- **Un solo runtime: Node 18+.** La app web, la API, los datos y el puente viven
|
|
23
|
-
en el mismo proceso/hogar.
|
|
24
|
-
- **opencode** es el único requisito externo (el motor que responde).
|
|
25
|
-
- **Túnel** con TunnelMole (gratis, sin cuenta), ngrok o Cloudflare para exponer
|
|
26
|
-
la app a internet.
|
|
27
|
-
|
|
28
|
-
## Requisitos
|
|
29
|
-
|
|
30
|
-
- [Node.js](https://nodejs.org) 18 o superior.
|
|
31
|
-
- `opencode` instalado y autenticado: `npm i -g opencode-ai`.
|
|
32
|
-
|
|
33
|
-
## Instalación
|
|
34
|
-
|
|
35
|
-
```bash
|
|
36
|
-
# opción 1: sin instalar nada (npx)
|
|
37
|
-
npx @danieltmn/openbridge init # asistente: workspace, contraseña, túnel, puerto
|
|
38
|
-
npx @danieltmn/openbridge server # arranca en segundo plano y muestra el estado/URL
|
|
39
|
-
|
|
40
|
-
# opción 2: instalar global
|
|
41
|
-
npm i -g @danieltmn/openbridge
|
|
42
|
-
openbridge init
|
|
43
|
-
openbridge server
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
Al arrancar (queda en segundo plano) imprime el **estado con la URL pública**
|
|
47
|
-
(`https://….tunnelmole.net/chat.php`): abríla desde el celular y logueate con el
|
|
48
|
-
usuario (`admin`) y la contraseña que elegiste en `init`. Para correrlo en
|
|
49
|
-
primer plano usá `openbridge server --stream`; para detenerlo, `openbridge stop`.
|
|
50
|
-
|
|
51
|
-
### Opciones de `init`
|
|
52
|
-
|
|
53
|
-
```
|
|
54
|
-
--workspace <ruta> carpeta de trabajo (donde están tus proyectos)
|
|
55
|
-
--name "PC 1" nombre visible de esta computadora
|
|
56
|
-
--id pc1 identificador corto (default: derivado del nombre)
|
|
57
|
-
--port 8799 puerto local
|
|
58
|
-
--password <clave> contraseña de acceso
|
|
59
|
-
--tunnel <prov> tunnelmole | ngrok | cloudflare | none
|
|
60
|
-
--
|
|
61
|
-
--
|
|
62
|
-
--
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
| `openbridge
|
|
71
|
-
| `openbridge
|
|
72
|
-
| `openbridge
|
|
73
|
-
| `openbridge
|
|
74
|
-
| `openbridge
|
|
75
|
-
| `openbridge
|
|
76
|
-
| `openbridge
|
|
77
|
-
| `openbridge
|
|
78
|
-
| `openbridge
|
|
79
|
-
| `openbridge
|
|
80
|
-
| `openbridge
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
`
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
1
|
+
# OpenBridge
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@danieltmn/openbridge)
|
|
4
|
+
[](https://github.com/tamnora/openbridge/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
Tu **opencode** en el celular, **sin hosting**: corre la app, el puente y un
|
|
8
|
+
túnel público desde tu PC. El celular entra por la URL del túnel y vos manejás
|
|
9
|
+
opencode (tus carpetas, modelos y chats) desde donde estés.
|
|
10
|
+
|
|
11
|
+
> Proyecto nuevo e independiente, portado a **Node** desde OpenConex (PHP).
|
|
12
|
+
> Ya no hace falta subir nada a un hosting ni depender de uno.
|
|
13
|
+
|
|
14
|
+
## Cómo funciona
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
[Celular] ──► URL del túnel ──► App Node en tu PC ──► .openbridge/data/ (JSON)
|
|
18
|
+
│
|
|
19
|
+
└──► bridge (Node) ──► opencode run
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
- **Un solo runtime: Node 18+.** La app web, la API, los datos y el puente viven
|
|
23
|
+
en el mismo proceso/hogar.
|
|
24
|
+
- **opencode** es el único requisito externo (el motor que responde).
|
|
25
|
+
- **Túnel** con TunnelMole (gratis, sin cuenta), ngrok o Cloudflare para exponer
|
|
26
|
+
la app a internet.
|
|
27
|
+
|
|
28
|
+
## Requisitos
|
|
29
|
+
|
|
30
|
+
- [Node.js](https://nodejs.org) 18 o superior.
|
|
31
|
+
- `opencode` instalado y autenticado: `npm i -g opencode-ai`.
|
|
32
|
+
|
|
33
|
+
## Instalación
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# opción 1: sin instalar nada (npx)
|
|
37
|
+
npx @danieltmn/openbridge init # asistente: workspace, contraseña, túnel, puerto
|
|
38
|
+
npx @danieltmn/openbridge server # arranca en segundo plano y muestra el estado/URL
|
|
39
|
+
|
|
40
|
+
# opción 2: instalar global
|
|
41
|
+
npm i -g @danieltmn/openbridge
|
|
42
|
+
openbridge init
|
|
43
|
+
openbridge server
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Al arrancar (queda en segundo plano) imprime el **estado con la URL pública**
|
|
47
|
+
(`https://….tunnelmole.net/chat.php`): abríla desde el celular y logueate con el
|
|
48
|
+
usuario (`admin`) y la contraseña que elegiste en `init`. Para correrlo en
|
|
49
|
+
primer plano usá `openbridge server --stream`; para detenerlo, `openbridge stop`.
|
|
50
|
+
|
|
51
|
+
### Opciones de `init`
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
--workspace <ruta> carpeta de trabajo (donde están tus proyectos)
|
|
55
|
+
--name "PC 1" nombre visible de esta computadora
|
|
56
|
+
--id pc1 identificador corto (default: derivado del nombre)
|
|
57
|
+
--port 8799 puerto local
|
|
58
|
+
--password <clave> contraseña de acceso
|
|
59
|
+
--tunnel <prov> tunnelmole | ngrok | cloudflare | none
|
|
60
|
+
--domain <host> dominio fijo del túnel (ngrok)
|
|
61
|
+
--yes sin preguntas (usa defaults)
|
|
62
|
+
--force reconfigura aunque ya exista
|
|
63
|
+
--dir <ruta> casa portable (default: directorio actual)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Comandos
|
|
67
|
+
|
|
68
|
+
| Comando | Qué hace |
|
|
69
|
+
|---|---|
|
|
70
|
+
| `openbridge init` | Configura la casa (workspace, contraseña, túnel, puerto) |
|
|
71
|
+
| `openbridge passwd` | Cambia la contraseña de acceso (detiene el server si corre) |
|
|
72
|
+
| `openbridge server` | Arranca app + puente + túnel en **segundo plano** y muestra el estado (`--stream` = primer plano) |
|
|
73
|
+
| `openbridge stop` | Detiene el server y su árbol de procesos |
|
|
74
|
+
| `openbridge status` | Estado, URL, puente en línea y chats |
|
|
75
|
+
| `openbridge qr` | Muestra la URL (pública o local) como QR para escanear desde el celular |
|
|
76
|
+
| `openbridge tunnel` | Muestra o cambia el proveedor de túnel y su dominio fijo (`--domain`) |
|
|
77
|
+
| `openbridge logs` | Logs (`--follow`, `--server`, `--bridge`) |
|
|
78
|
+
| `openbridge bridge` | Corre **solo** el puente (`--api --token --id --name`) |
|
|
79
|
+
| `openbridge import` | Trae `data/` de OpenConex (`<data-dir> [--force]`) |
|
|
80
|
+
| `openbridge reset` | Borra chats/datos (`--session <id>`, `--yes`) |
|
|
81
|
+
| `openbridge autostart` | Arranque automático (`install`/`remove`) |
|
|
82
|
+
| `openbridge doctor` | Verifica Node, opencode, configuración y puerto |
|
|
83
|
+
|
|
84
|
+
## Casa portable
|
|
85
|
+
|
|
86
|
+
Todo vive dentro de **`.openbridge/`** en el directorio donde corrés `init`
|
|
87
|
+
(o `--dir` / `OPENBRIDGE_HOME`):
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
.openbridge/
|
|
91
|
+
config.json config del puente (apiUrl, token, workspace, bridgeId…)
|
|
92
|
+
app.json config de la app (usuario, contraseña, VAPID, puerto, túnel)
|
|
93
|
+
folders.json lista blanca de carpetas que se ven desde el celular
|
|
94
|
+
data/ sesiones, mensajes, catálogo, registro de puentes, push
|
|
95
|
+
logs/ server.log y bridge.log
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Movés la carpeta a donde quieras y sigue funcionando (es portable). Si venías
|
|
99
|
+
de una versión anterior con los archivos sueltos, se **migran solos** a
|
|
100
|
+
`.openbridge/` la primera vez que corras un comando.
|
|
101
|
+
|
|
102
|
+
## Varias computadoras
|
|
103
|
+
|
|
104
|
+
El hub (la PC con la app) puede atender **varias PCs** a la vez:
|
|
105
|
+
|
|
106
|
+
- En la PC "hub": `openbridge init` + `openbridge server` (queda la app + túnel).
|
|
107
|
+
- En otra PC: `openbridge init` y luego `openbridge bridge --api <url-del-hub>
|
|
108
|
+
--token <token> --id pc2 --name "PC 2"` (sin editar `config.json` a mano).
|
|
109
|
+
|
|
110
|
+
En el sidebar de la web elegís qué PC usar y ves sus proyectos y chats.
|
|
111
|
+
|
|
112
|
+
## Túnel y URL estable
|
|
113
|
+
|
|
114
|
+
Proveedores soportados (`--tunnel`): `tunnelmole` (gratis, sin cuenta),
|
|
115
|
+
`ngrok` (cuenta + authtoken; admite dominio fijo con `--domain` en
|
|
116
|
+
`app.json`) y `cloudflare` (quick tunnel de `cloudflared`). Con URL
|
|
117
|
+
**aleatoria**, la suscripción push no persiste entre reinicios (el push está
|
|
118
|
+
atado al origen). Para PWA/push estables conviene ngrok con dominio fijo o
|
|
119
|
+
Cloudflare con dominio propio.
|
|
120
|
+
|
|
121
|
+
## Avisos push
|
|
122
|
+
|
|
123
|
+
Las claves VAPID se generan solas en `init`. Con URL de túnel **aleatoria**, la
|
|
124
|
+
suscripción push no persiste entre reinicios (el push está atado al origen). Para
|
|
125
|
+
una URL fija hace falta un proveedor con dominio estable (ngrok/Cloudflare).
|
|
126
|
+
|
|
127
|
+
## Seguridad
|
|
128
|
+
|
|
129
|
+
- La app escucha solo en `127.0.0.1`; el túnel la expone.
|
|
130
|
+
- Login con contraseña (scrypt) + CSRF y rate limit (5 intentos); token del
|
|
131
|
+
puente autogenerado.
|
|
132
|
+
- El túnel es **público mientras corre**: detenelo (`openbridge stop`) cuando no
|
|
133
|
+
lo uses.
|
|
134
|
+
|
|
135
|
+
## Estado
|
|
136
|
+
|
|
137
|
+
Proyecto en desarrollo. Ya funciona: `init`, `server` (segundo plano; `--stream`
|
|
138
|
+
en primer plano), `stop`, `status`, `logs` (`--follow`), `bridge` (con flags),
|
|
139
|
+
`passwd`, `import`, `reset`, `autostart`, `doctor`, login con rate limit, API
|
|
140
|
+
completa, SSE, catálogo por PC, Web Push y túnel (TunnelMole/ngrok/cloudflare).
|
|
141
|
+
Tests en `npm test`. Pendiente: re-suscripción push al cambiar la URL y más
|
|
142
|
+
cobertura end-to-end.
|
|
143
|
+
|
|
144
|
+
## Licencia
|
|
145
|
+
|
|
146
|
+
MIT.
|
package/package.json
CHANGED
package/src/bridge/bridge.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
'use strict';
|
|
14
14
|
|
|
15
|
-
const { spawn } = require('node:child_process');
|
|
15
|
+
const { spawn, spawnSync } = require('node:child_process');
|
|
16
16
|
const fs = require('node:fs');
|
|
17
17
|
const os = require('node:os');
|
|
18
18
|
const path = require('node:path');
|
|
@@ -1549,6 +1549,77 @@ async function handleFsCommand(cmd) {
|
|
|
1549
1549
|
}
|
|
1550
1550
|
}
|
|
1551
1551
|
|
|
1552
|
+
// ---------------------------------------------------------------------------
|
|
1553
|
+
// Cambios del proyecto (git status / git diff) para revisar lo que tocó el
|
|
1554
|
+
// agente desde el celular. Corre git en la carpeta de la sesión (validada
|
|
1555
|
+
// dentro del workspace, igual que proc_start).
|
|
1556
|
+
// ---------------------------------------------------------------------------
|
|
1557
|
+
function parseGitStatus(text) {
|
|
1558
|
+
const files = [];
|
|
1559
|
+
let branch = '';
|
|
1560
|
+
for (const line of String(text).split('\n')) {
|
|
1561
|
+
if (line === '') continue;
|
|
1562
|
+
if (line.startsWith('## ')) { branch = line.slice(3).trim(); continue; }
|
|
1563
|
+
const status = line.slice(0, 2).trim() || '?';
|
|
1564
|
+
let p = line.slice(3);
|
|
1565
|
+
const arrow = p.indexOf(' -> ');
|
|
1566
|
+
if (arrow >= 0) p = p.slice(arrow + 4);
|
|
1567
|
+
files.push({ status, path: p });
|
|
1568
|
+
}
|
|
1569
|
+
return { branch, files };
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
function gitIn(folder, args, timeout = 30000) {
|
|
1573
|
+
const r = spawnSync('git', ['-C', folder].concat(args), {
|
|
1574
|
+
encoding: 'utf8',
|
|
1575
|
+
timeout,
|
|
1576
|
+
windowsHide: true,
|
|
1577
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
1578
|
+
});
|
|
1579
|
+
if (r.error) throw new Error(r.error.message || 'no se pudo ejecutar git');
|
|
1580
|
+
const text = String(r.stdout || '');
|
|
1581
|
+
return { code: r.status, text, err: String(r.stderr || '').trim() };
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
async function handleGitCommand(cmd) {
|
|
1585
|
+
let folder;
|
|
1586
|
+
try { folder = procResolveFolder(cmd.args && cmd.args[0]); }
|
|
1587
|
+
catch (e) {
|
|
1588
|
+
await api('command_done', { id: cmd.id, ok: false, text: '', error: e.message }, cmd._t);
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
try {
|
|
1592
|
+
if (cmd.name === 'git_status') {
|
|
1593
|
+
const r = gitIn(folder, ['status', '--porcelain=v1', '-b', '--untracked-files=all']);
|
|
1594
|
+
if (r.code !== 0) throw new Error(r.err || 'no es un repositorio git');
|
|
1595
|
+
await api('command_done', { id: cmd.id, ok: true, text: JSON.stringify(parseGitStatus(r.text)), error: '' }, cmd._t);
|
|
1596
|
+
} else if (cmd.name === 'git_checkout') {
|
|
1597
|
+
// Revierte cambios de archivos RASTREADOS dentro de la carpeta de la
|
|
1598
|
+
// sesion. Sin 2do argumento revierte todo (`.`); con argumento,
|
|
1599
|
+
// solo ese archivo relativo (sin `..` ni rutas absolutas).
|
|
1600
|
+
let target = (cmd.args && cmd.args.length > 1) ? String(cmd.args[1]) : '.';
|
|
1601
|
+
if (target !== '.') {
|
|
1602
|
+
if (path.isAbsolute(target) || target.split(/[\\/]/).includes('..')) {
|
|
1603
|
+
throw new Error('ruta invalida');
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
const r = gitIn(folder, ['checkout', '--', target]);
|
|
1607
|
+
if (r.code !== 0) throw new Error(r.err || 'no se pudo revertir');
|
|
1608
|
+
await api('command_done', { id: cmd.id, ok: true, text: JSON.stringify({ reverted: target }), error: '' }, cmd._t);
|
|
1609
|
+
} else {
|
|
1610
|
+
// diff HEAD: cambios preparados + sin preparar. `fs_result` admite
|
|
1611
|
+
// respuestas mas grandes que command_done (8000 car.).
|
|
1612
|
+
const r = gitIn(folder, ['--no-pager', 'diff', 'HEAD', '--no-color', '--no-ext-diff', '--no-renames']);
|
|
1613
|
+
if (r.code !== 0) throw new Error(r.err || 'no es un repositorio git');
|
|
1614
|
+
await api('fs_result', { id: cmd.id, ok: true, text: JSON.stringify({ diff: r.text }), error: '' }, cmd._t);
|
|
1615
|
+
}
|
|
1616
|
+
log('git ' + cmd.name + ' ok en ' + folder);
|
|
1617
|
+
} catch (e) {
|
|
1618
|
+
await api('command_done', { id: cmd.id, ok: false, text: '', error: e.message }, cmd._t);
|
|
1619
|
+
log('git ' + cmd.name + ' error: ' + e.message);
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1552
1623
|
// ---------------------------------------------------------------------------
|
|
1553
1624
|
// Procesa comandos read-only encolados por la web.
|
|
1554
1625
|
// ---------------------------------------------------------------------------
|
|
@@ -2044,6 +2115,11 @@ async function handleCommand(cmd) {
|
|
|
2044
2115
|
await handleFsCommand(cmd);
|
|
2045
2116
|
return;
|
|
2046
2117
|
}
|
|
2118
|
+
// Cambios git del proyecto (status/diff/revertir).
|
|
2119
|
+
if (cmd.name === 'git_status' || cmd.name === 'git_diff' || cmd.name === 'git_checkout') {
|
|
2120
|
+
await handleGitCommand(cmd);
|
|
2121
|
+
return;
|
|
2122
|
+
}
|
|
2047
2123
|
// Túneles también son del puente (procesos de esta PC).
|
|
2048
2124
|
if (cmd.name === 'tunnel_start') { await tunnelStart(cmd); return; }
|
|
2049
2125
|
if (cmd.name === 'tunnel_stop') { await tunnelStop(cmd); return; }
|
package/src/cli.js
CHANGED
|
@@ -244,7 +244,7 @@ async function cmdInit(argv) {
|
|
|
244
244
|
csrfSecret,
|
|
245
245
|
bridgeToken,
|
|
246
246
|
vapid,
|
|
247
|
-
tunnel: { provider, domain: '' },
|
|
247
|
+
tunnel: { provider, domain: flags.domain ? String(flags.domain) : '' },
|
|
248
248
|
};
|
|
249
249
|
config.writeBridge(bridgeCfg);
|
|
250
250
|
config.writeApp(appCfg);
|
|
@@ -488,6 +488,51 @@ async function cmdQr() {
|
|
|
488
488
|
return 0;
|
|
489
489
|
}
|
|
490
490
|
|
|
491
|
+
// Muestra o cambia el proveedor de tunel (y su dominio fijo) sin reconfigurar
|
|
492
|
+
// todo. Cambiar el proveedor requiere reiniciar el server.
|
|
493
|
+
async function cmdTunnel(argv) {
|
|
494
|
+
const { flags, _ } = parseArgs(argv);
|
|
495
|
+
if (!paths.exists()) {
|
|
496
|
+
console.error('No hay configuracion. Corre primero: openbridge init');
|
|
497
|
+
return 1;
|
|
498
|
+
}
|
|
499
|
+
const app = config.readApp();
|
|
500
|
+
const cur = app.tunnel || { provider: 'tunnelmole', domain: '' };
|
|
501
|
+
const arg = String(_[0] || '').toLowerCase();
|
|
502
|
+
|
|
503
|
+
if (arg === '' || arg === 'status' || arg === 'show') {
|
|
504
|
+
const rt = readRuntime();
|
|
505
|
+
const running = rt && pidAlive(rt.pid);
|
|
506
|
+
console.log('proveedor : ' + (cur.provider || 'tunnelmole'));
|
|
507
|
+
console.log('dominio : ' + (cur.domain || '(aleatorio)'));
|
|
508
|
+
console.log('estado : ' + (running ? 'corriendo' : 'detenido'));
|
|
509
|
+
if (running && rt.publicUrl) console.log('publico : ' + rt.publicUrl);
|
|
510
|
+
return 0;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
let provider = arg;
|
|
514
|
+
if (provider === 'cloudflared') provider = 'cloudflare';
|
|
515
|
+
if (!['tunnelmole', 'ngrok', 'cloudflare', 'none'].includes(provider)) {
|
|
516
|
+
console.error('Proveedor desconocido: ' + arg + ' (usar tunnelmole|ngrok|cloudflare|none)');
|
|
517
|
+
return 1;
|
|
518
|
+
}
|
|
519
|
+
const domain = flags.domain !== undefined ? String(flags.domain).trim() : String(cur.domain || '');
|
|
520
|
+
app.tunnel = { provider, domain };
|
|
521
|
+
config.writeApp(app);
|
|
522
|
+
console.log('Tunel configurado: ' + provider + (domain ? ' (dominio ' + domain + ')' : ''));
|
|
523
|
+
if (provider === 'ngrok' && !domain) {
|
|
524
|
+
console.log('aviso: ngrok sin dominio fijo da URL aleatoria; pasá --domain <sub.ngrok.app> para una estable.');
|
|
525
|
+
}
|
|
526
|
+
if (provider === 'cloudflare' && domain) {
|
|
527
|
+
console.log('aviso: el quick tunnel de cloudflared ignora el dominio (URL aleatoria).');
|
|
528
|
+
}
|
|
529
|
+
const rt = readRuntime();
|
|
530
|
+
if (rt && pidAlive(rt.pid)) {
|
|
531
|
+
console.log('Hay un server corriendo: reinicialo para aplicar (openbridge stop && openbridge server).');
|
|
532
|
+
}
|
|
533
|
+
return 0;
|
|
534
|
+
}
|
|
535
|
+
|
|
491
536
|
async function cmdLogs(argv) {
|
|
492
537
|
const { flags } = parseArgs(argv);
|
|
493
538
|
const n = parseInt(flags.n || '40', 10) || 40;
|
|
@@ -785,6 +830,7 @@ function usage() {
|
|
|
785
830
|
console.log(' stop Detiene el server en segundo plano');
|
|
786
831
|
console.log(' status Estado del server, puente y chats');
|
|
787
832
|
console.log(' qr Muestra la URL (publica o local) como QR para el celular');
|
|
833
|
+
console.log(' tunnel Muestra o cambia el proveedor de tunel (tunnelmole|ngrok|cloudflare|none) [--domain]');
|
|
788
834
|
console.log(' logs Ultimas lineas de los logs (--follow --server --bridge)');
|
|
789
835
|
console.log(' bridge Corre solo el puente (--api --token --id --name)');
|
|
790
836
|
console.log(' import Trae data/ de OpenConex (<data-dir> [--force])');
|
|
@@ -793,7 +839,7 @@ function usage() {
|
|
|
793
839
|
console.log(' doctor Verifica Node, opencode, configuracion y puerto');
|
|
794
840
|
console.log('');
|
|
795
841
|
console.log('Opciones comunes: --dir <ruta> (casa portable; default: directorio actual)');
|
|
796
|
-
console.log('init: --workspace --name --id --port --password --tunnel --yes --force');
|
|
842
|
+
console.log('init: --workspace --name --id --port --password --tunnel --domain --yes --force');
|
|
797
843
|
console.log('server: --port --no-tunnel --stream (sin --stream corre en segundo plano)');
|
|
798
844
|
}
|
|
799
845
|
|
|
@@ -816,6 +862,7 @@ async function main(argv) {
|
|
|
816
862
|
case 'stop': return cmdStop();
|
|
817
863
|
case 'status': return cmdStatus();
|
|
818
864
|
case 'qr': return cmdQr();
|
|
865
|
+
case 'tunnel': return cmdTunnel(args.slice(1));
|
|
819
866
|
case 'logs': return cmdLogs(args.slice(1));
|
|
820
867
|
case 'bridge': return cmdBridge(args.slice(1));
|
|
821
868
|
case 'import': return cmdImport(args.slice(1));
|
package/src/web/assets/app.js
CHANGED
|
@@ -28,6 +28,8 @@ var els = {
|
|
|
28
28
|
sendBtn: document.getElementById('sendBtn'),
|
|
29
29
|
btnImg: document.getElementById('btnImg'),
|
|
30
30
|
btnMic: document.getElementById('btnMic'),
|
|
31
|
+
btnTpl: document.getElementById('btnTpl'),
|
|
32
|
+
tplPanel: document.getElementById('tplPanel'),
|
|
31
33
|
imgInput: document.getElementById('imgInput'),
|
|
32
34
|
imgPreview: document.getElementById('imgPreview'),
|
|
33
35
|
imgThumb: document.getElementById('imgThumb'),
|
|
@@ -45,6 +47,7 @@ var els = {
|
|
|
45
47
|
btnSkin: document.getElementById('btnSkin'),
|
|
46
48
|
skinPanel: document.getElementById('skinPanel'),
|
|
47
49
|
viewProcs: document.getElementById('viewProcs'),
|
|
50
|
+
viewChanges: document.getElementById('viewChanges'),
|
|
48
51
|
themeColor: document.getElementById('themeColor'),
|
|
49
52
|
themeLink: document.getElementById('themeStylesheet'),
|
|
50
53
|
statusbar: document.getElementById('statusbar'),
|
|
@@ -980,6 +983,7 @@ function showView(name) {
|
|
|
980
983
|
els.viewHistory.style.display = 'none';
|
|
981
984
|
els.viewPreview.style.display = 'none';
|
|
982
985
|
els.viewProcs.style.display = 'none';
|
|
986
|
+
els.viewChanges.style.display = 'none';
|
|
983
987
|
|
|
984
988
|
if (name !== 'procs') stopProcView();
|
|
985
989
|
|
|
@@ -1046,6 +1050,12 @@ function showView(name) {
|
|
|
1046
1050
|
var pf = sessionProcFolder();
|
|
1047
1051
|
els.hSub.textContent = pf ? projectLabel(pf) : 'de la sesión abierta';
|
|
1048
1052
|
enterProcsView();
|
|
1053
|
+
} else if (name === 'changes') {
|
|
1054
|
+
els.viewChanges.style.display = '';
|
|
1055
|
+
els.btnBack.style.display = 'none';
|
|
1056
|
+
els.sendForm.style.display = 'none';
|
|
1057
|
+
els.btnCmds.style.display = 'none';
|
|
1058
|
+
renderChangesView();
|
|
1049
1059
|
}
|
|
1050
1060
|
|
|
1051
1061
|
if (els.fabNew) els.fabNew.classList.toggle('show', name === 'home');
|
|
@@ -1098,6 +1108,7 @@ function composeOpenSheet() {
|
|
|
1098
1108
|
function composeCloseSheet() {
|
|
1099
1109
|
if (!els.sendForm) return;
|
|
1100
1110
|
voiceStop();
|
|
1111
|
+
tplClose();
|
|
1101
1112
|
els.sendForm.classList.remove('open');
|
|
1102
1113
|
document.body.classList.remove('compose-open');
|
|
1103
1114
|
if (els.imgPreview && els.imgPreview.parentNode === els.sendForm && els.composeOpen) {
|
|
@@ -2485,6 +2496,85 @@ if (els.btnMic) {
|
|
|
2485
2496
|
}
|
|
2486
2497
|
}
|
|
2487
2498
|
|
|
2499
|
+
// ---------------------------------------------------------------------------
|
|
2500
|
+
// Plantillas de prompts (atajos guardados en el navegador).
|
|
2501
|
+
// ---------------------------------------------------------------------------
|
|
2502
|
+
var TPL_KEY = 'ob_tpl';
|
|
2503
|
+
|
|
2504
|
+
function tplLoad() {
|
|
2505
|
+
try {
|
|
2506
|
+
var a = JSON.parse(localStorage.getItem(TPL_KEY) || '[]');
|
|
2507
|
+
if (!Array.isArray(a)) return [];
|
|
2508
|
+
return a.filter(function (x) { return typeof x === 'string' && x.trim() !== ''; }).slice(0, 30);
|
|
2509
|
+
} catch (e) { return []; }
|
|
2510
|
+
}
|
|
2511
|
+
function tplStore(list) {
|
|
2512
|
+
try { localStorage.setItem(TPL_KEY, JSON.stringify(list)); } catch (e) {}
|
|
2513
|
+
}
|
|
2514
|
+
function tplClose() {
|
|
2515
|
+
if (els.tplPanel) els.tplPanel.style.display = 'none';
|
|
2516
|
+
}
|
|
2517
|
+
function tplRender() {
|
|
2518
|
+
var list = tplLoad();
|
|
2519
|
+
var html = '';
|
|
2520
|
+
for (var i = 0; i < list.length; i++) {
|
|
2521
|
+
html += '<div class="tpl-row" data-tpl="' + i + '"><span class="tpl-text">' + esc(list[i]) + '</span>'
|
|
2522
|
+
+ '<button type="button" class="tpl-del" data-del="' + i + '" title="Borrar plantilla" aria-label="Borrar plantilla">✕</button></div>';
|
|
2523
|
+
}
|
|
2524
|
+
if (!list.length) html += '<div class="tpl-empty">No hay plantillas. Escribí un prompt y tocá “guardar lo escrito”.</div>';
|
|
2525
|
+
html += '<button type="button" class="tpl-save" id="tplSave">+ guardar lo escrito</button>';
|
|
2526
|
+
els.tplPanel.innerHTML = html;
|
|
2527
|
+
}
|
|
2528
|
+
function tplOpen() {
|
|
2529
|
+
closeSlash();
|
|
2530
|
+
tplRender();
|
|
2531
|
+
els.tplPanel.style.display = 'block';
|
|
2532
|
+
}
|
|
2533
|
+
function tplInsert(text) {
|
|
2534
|
+
var cur = els.input.value;
|
|
2535
|
+
els.input.value = cur && cur.trim() !== '' ? (cur.replace(/\s+$/, '') + ' ' + text) : text;
|
|
2536
|
+
tplClose();
|
|
2537
|
+
autoGrow();
|
|
2538
|
+
els.input.focus();
|
|
2539
|
+
try { els.input.setSelectionRange(els.input.value.length, els.input.value.length); } catch (e) {}
|
|
2540
|
+
}
|
|
2541
|
+
function tplSaveCurrent() {
|
|
2542
|
+
var text = els.input.value.trim();
|
|
2543
|
+
if (!text) { toast('escribí un prompt primero', 'error'); return; }
|
|
2544
|
+
var list = tplLoad();
|
|
2545
|
+
if (list.indexOf(text) >= 0) { toast('esa plantilla ya existe', 'ok'); return; }
|
|
2546
|
+
list.unshift(text);
|
|
2547
|
+
tplStore(list.slice(0, 30));
|
|
2548
|
+
toast('plantilla guardada', 'ok');
|
|
2549
|
+
tplRender();
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
if (els.btnTpl && els.tplPanel) {
|
|
2553
|
+
els.btnTpl.addEventListener('click', function () {
|
|
2554
|
+
if (state.currentId === null) return;
|
|
2555
|
+
if (els.tplPanel.style.display === 'block') tplClose();
|
|
2556
|
+
else tplOpen();
|
|
2557
|
+
});
|
|
2558
|
+
els.tplPanel.addEventListener('click', function (e) {
|
|
2559
|
+
var del = e.target.closest('.tpl-del');
|
|
2560
|
+
if (del) {
|
|
2561
|
+
e.stopPropagation();
|
|
2562
|
+
var list = tplLoad();
|
|
2563
|
+
list.splice(parseInt(del.getAttribute('data-del'), 10), 1);
|
|
2564
|
+
tplStore(list);
|
|
2565
|
+
tplRender();
|
|
2566
|
+
return;
|
|
2567
|
+
}
|
|
2568
|
+
if (e.target.closest('.tpl-save')) { tplSaveCurrent(); return; }
|
|
2569
|
+
var row = e.target.closest('.tpl-row');
|
|
2570
|
+
if (row) {
|
|
2571
|
+
var list2 = tplLoad();
|
|
2572
|
+
var t = list2[parseInt(row.getAttribute('data-tpl'), 10)];
|
|
2573
|
+
if (typeof t === 'string') tplInsert(t);
|
|
2574
|
+
}
|
|
2575
|
+
});
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2488
2578
|
// ---------------------------------------------------------------------------
|
|
2489
2579
|
// Composer: textarea multilínea (Enter envía, Shift+Enter salta de línea)
|
|
2490
2580
|
// y menú de comandos "/" con filtro, flechas y Enter.
|
|
@@ -2532,6 +2622,7 @@ els.input.addEventListener('input', function () {
|
|
|
2532
2622
|
autoGrow();
|
|
2533
2623
|
var v = els.input.value;
|
|
2534
2624
|
if (state.view === 'chat' && v.charAt(0) === '/') {
|
|
2625
|
+
tplClose();
|
|
2535
2626
|
slash.items = slashFilter(v);
|
|
2536
2627
|
slash.sel = 0;
|
|
2537
2628
|
renderSlash();
|
|
@@ -2564,7 +2655,8 @@ els.input.addEventListener('keydown', function (e) {
|
|
|
2564
2655
|
}
|
|
2565
2656
|
if (e.key === 'Escape') {
|
|
2566
2657
|
e.stopPropagation();
|
|
2567
|
-
|
|
2658
|
+
if (els.tplPanel && els.tplPanel.style.display === 'block') tplClose();
|
|
2659
|
+
else closeSlash();
|
|
2568
2660
|
return;
|
|
2569
2661
|
}
|
|
2570
2662
|
}
|
|
@@ -2602,6 +2694,7 @@ els.sendForm.addEventListener('submit', async function (ev) {
|
|
|
2602
2694
|
els.input.value = '';
|
|
2603
2695
|
autoGrow();
|
|
2604
2696
|
closeSlash();
|
|
2697
|
+
tplClose();
|
|
2605
2698
|
setPendingImage(null);
|
|
2606
2699
|
await loadHistory(state.currentId, true);
|
|
2607
2700
|
} else {
|
|
@@ -2881,6 +2974,132 @@ function filesJoin(name) {
|
|
|
2881
2974
|
return state.filesPath ? state.filesPath + '/' + name : name;
|
|
2882
2975
|
}
|
|
2883
2976
|
|
|
2977
|
+
// ---------------------------------------------------------------------------
|
|
2978
|
+
// Cambios del proyecto (git status/diff) vía la cola del puente.
|
|
2979
|
+
// ---------------------------------------------------------------------------
|
|
2980
|
+
function changesFolder() {
|
|
2981
|
+
return (state.currentSession && state.currentSession.folder) || '';
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2984
|
+
function renderChangesView() {
|
|
2985
|
+
els.hTitle.textContent = 'cambios';
|
|
2986
|
+
var folder = changesFolder();
|
|
2987
|
+
els.hSub.textContent = folder ? projectLabel(folder) : 'de la sesión abierta';
|
|
2988
|
+
if (!folder) {
|
|
2989
|
+
els.viewChanges.innerHTML = '<div class="placeholder">Abrí un chat para ver los cambios de su proyecto.</div>';
|
|
2990
|
+
return;
|
|
2991
|
+
}
|
|
2992
|
+
if (!state.online) {
|
|
2993
|
+
els.viewChanges.innerHTML = '<div class="placeholder">El puente está apagado. Iniciá “OpenBridge” en tu PC.</div>';
|
|
2994
|
+
return;
|
|
2995
|
+
}
|
|
2996
|
+
els.viewChanges.innerHTML = '<div class="view-head"><h2>cambios sin commitear</h2>'
|
|
2997
|
+
+ '<div class="view-sub">' + esc(projectLabel(folder)) + '</div></div>'
|
|
2998
|
+
+ '<div class="placeholder">consultando git…</div>';
|
|
2999
|
+
loadChanges(folder);
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
function chgStatusClass(st) {
|
|
3003
|
+
if (st === '??' || st === 'A') return 'add';
|
|
3004
|
+
if (st.indexOf('D') >= 0) return 'del';
|
|
3005
|
+
if (st.indexOf('M') >= 0 || st.indexOf('R') >= 0) return 'mod';
|
|
3006
|
+
return '';
|
|
3007
|
+
}
|
|
3008
|
+
|
|
3009
|
+
function renderDiff(text) {
|
|
3010
|
+
var max = 200000;
|
|
3011
|
+
var trunc = false;
|
|
3012
|
+
if (text.length > max) { text = text.slice(0, max); trunc = true; }
|
|
3013
|
+
var lines = text.split('\n');
|
|
3014
|
+
var html = '';
|
|
3015
|
+
for (var i = 0; i < lines.length; i++) {
|
|
3016
|
+
var l = lines[i];
|
|
3017
|
+
var cls = 'diff-line';
|
|
3018
|
+
if (l.charAt(0) === '+') cls += ' add';
|
|
3019
|
+
else if (l.charAt(0) === '-') cls += ' del';
|
|
3020
|
+
else if (l.indexOf('@@') === 0) cls += ' hunk';
|
|
3021
|
+
else if (l.indexOf('diff --git') === 0 || l.indexOf('index ') === 0 || l.indexOf('--- ') === 0 || l.indexOf('+++ ') === 0) cls += ' head';
|
|
3022
|
+
html += '<div class="' + cls + '">' + esc(l || ' ') + '</div>';
|
|
3023
|
+
}
|
|
3024
|
+
if (trunc) html += '<div class="diff-line head">… (diff recortado)</div>';
|
|
3025
|
+
return '<div class="diff">' + html + '</div>';
|
|
3026
|
+
}
|
|
3027
|
+
|
|
3028
|
+
function changesHead(folder, branch) {
|
|
3029
|
+
return '<div class="view-head"><h2>cambios sin commitear</h2><div class="view-sub">'
|
|
3030
|
+
+ esc(projectLabel(folder)) + (branch ? ' · ' + esc(branch) : '') + '</div></div>';
|
|
3031
|
+
}
|
|
3032
|
+
|
|
3033
|
+
async function loadChanges(folder) {
|
|
3034
|
+
var st = await ocCommand('git_status', [folder], 15, 500);
|
|
3035
|
+
if (!st.ok) {
|
|
3036
|
+
els.viewChanges.innerHTML = changesHead(folder, '')
|
|
3037
|
+
+ '<div class="placeholder">' + esc(st.error || 'no se pudo consultar git') + '</div>';
|
|
3038
|
+
return;
|
|
3039
|
+
}
|
|
3040
|
+
var status = st.data || {};
|
|
3041
|
+
var files = status.files || [];
|
|
3042
|
+
if (!files.length) {
|
|
3043
|
+
els.viewChanges.innerHTML = changesHead(folder, status.branch)
|
|
3044
|
+
+ '<div class="placeholder">Sin cambios: el árbol está limpio.</div>';
|
|
3045
|
+
return;
|
|
3046
|
+
}
|
|
3047
|
+
var list = '<div class="chg-files">';
|
|
3048
|
+
for (var i = 0; i < files.length; i++) {
|
|
3049
|
+
var rev = files[i].status !== '??'
|
|
3050
|
+
? '<button type="button" class="chg-rev" data-path="' + esc(files[i].path) + '" title="Revertir este archivo" aria-label="Revertir este archivo">↩</button>'
|
|
3051
|
+
: '';
|
|
3052
|
+
list += '<div class="chg-row"><span class="chg-st ' + chgStatusClass(files[i].status) + '">' + esc(files[i].status) + '</span>'
|
|
3053
|
+
+ '<span class="chg-path">' + esc(files[i].path) + '</span>' + rev + '</div>';
|
|
3054
|
+
}
|
|
3055
|
+
list += '</div>';
|
|
3056
|
+
var tracked = files.some(function (f) { return f.status !== '??'; });
|
|
3057
|
+
var toolbar = tracked
|
|
3058
|
+
? '<div class="files-toolbar"><button type="button" class="linkbtn" id="chgRevert">↩ revertir cambios rastreados</button></div>'
|
|
3059
|
+
: '';
|
|
3060
|
+
els.viewChanges.innerHTML = changesHead(folder, status.branch) + toolbar + list + '<div class="placeholder">cargando diff…</div>';
|
|
3061
|
+
|
|
3062
|
+
var diff = await ocCommand('git_diff', [folder], 20, 700);
|
|
3063
|
+
var diffHtml;
|
|
3064
|
+
if (diff.ok) {
|
|
3065
|
+
var text = (diff.data && diff.data.diff) || '';
|
|
3066
|
+
diffHtml = text ? renderDiff(text) : '<div class="placeholder">Hay archivos nuevos o renombrados sin diff de contenido.</div>';
|
|
3067
|
+
} else {
|
|
3068
|
+
diffHtml = '<div class="placeholder">' + esc(diff.error || 'no se pudo obtener el diff') + '</div>';
|
|
3069
|
+
}
|
|
3070
|
+
els.viewChanges.innerHTML = changesHead(folder, status.branch) + toolbar + list + diffHtml;
|
|
3071
|
+
}
|
|
3072
|
+
|
|
3073
|
+
// Revertir cambios rastreados (con confirmacion): borra las modificaciones que
|
|
3074
|
+
// el agente u otros hicieron en archivos ya versionados. No toca los nuevos.
|
|
3075
|
+
// Sirve para un archivo puntual (boton de la fila) o para todos.
|
|
3076
|
+
if (els.viewChanges) {
|
|
3077
|
+
els.viewChanges.addEventListener('click', function (e) {
|
|
3078
|
+
var folder = changesFolder();
|
|
3079
|
+
if (!folder) return;
|
|
3080
|
+
var one = e.target.closest('.chg-rev');
|
|
3081
|
+
if (one) {
|
|
3082
|
+
var rel = one.getAttribute('data-path');
|
|
3083
|
+
if (!window.confirm('¿Revertir “' + rel + '”?\n\nNo se puede deshacer.')) return;
|
|
3084
|
+
one.disabled = true;
|
|
3085
|
+
ocCommand('git_checkout', [folder, rel], 15, 500).then(function (r) {
|
|
3086
|
+
toast(r.ok ? 'revertido: ' + rel : (r.error || 'no se pudo revertir'), r.ok ? 'ok' : 'error');
|
|
3087
|
+
renderChangesView();
|
|
3088
|
+
});
|
|
3089
|
+
return;
|
|
3090
|
+
}
|
|
3091
|
+
var all = e.target.closest('#chgRevert');
|
|
3092
|
+
if (!all) return;
|
|
3093
|
+
if (!window.confirm('¿Revertir TODOS los cambios de archivos rastreados en “' + projectLabel(folder) + '”?\n\nNo se puede deshacer. Los archivos nuevos sin seguimiento no se tocan.')) return;
|
|
3094
|
+
all.disabled = true;
|
|
3095
|
+
all.textContent = 'revirtiendo…';
|
|
3096
|
+
ocCommand('git_checkout', [folder], 15, 500).then(function (r) {
|
|
3097
|
+
toast(r.ok ? 'cambios revertidos' : (r.error || 'no se pudo revertir'), r.ok ? 'ok' : 'error');
|
|
3098
|
+
renderChangesView();
|
|
3099
|
+
});
|
|
3100
|
+
});
|
|
3101
|
+
}
|
|
3102
|
+
|
|
2884
3103
|
function renderFilesView() {
|
|
2885
3104
|
state.fileView = null;
|
|
2886
3105
|
var ws = (state.catalog && state.catalog.workspace) || (state.filesWorkspace || '');
|
package/src/web/routes.js
CHANGED
|
@@ -171,6 +171,9 @@ const OC_ALLOWED = {
|
|
|
171
171
|
opencode_version: [],
|
|
172
172
|
fs_list: ['path'],
|
|
173
173
|
fs_read: ['path'],
|
|
174
|
+
git_status: ['path'],
|
|
175
|
+
git_diff: ['path'],
|
|
176
|
+
git_checkout: ['path', 'path'],
|
|
174
177
|
tunnel_start: ['arg'],
|
|
175
178
|
tunnel_stop: ['arg'],
|
|
176
179
|
tunnel_list: [],
|
|
@@ -768,6 +768,17 @@
|
|
|
768
768
|
.slash-panel .item.sel, .slash-panel .item:hover { background: var(--accent-soft); }
|
|
769
769
|
.slash-panel .item b { color: var(--mine); font-weight: 600; }
|
|
770
770
|
.slash-panel .item span { display: block; color: var(--muted); font-size: 11px; margin-top: 1px; }
|
|
771
|
+
.tpl-panel .tpl-row { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 8px; cursor: pointer; font-size: 13.5px; }
|
|
772
|
+
.tpl-panel .tpl-row:hover { background: var(--accent-soft); }
|
|
773
|
+
.tpl-panel .tpl-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
774
|
+
.tpl-panel .tpl-del { background: transparent; border: 0; color: var(--muted); cursor: pointer; font: inherit; padding: 2px 6px; border-radius: 6px; flex-shrink: 0; }
|
|
775
|
+
.tpl-panel .tpl-del:hover { color: var(--danger); }
|
|
776
|
+
.tpl-panel .tpl-empty { color: var(--muted); font-size: 12px; padding: 8px 10px; line-height: 1.4; }
|
|
777
|
+
.tpl-panel .tpl-save {
|
|
778
|
+
display: block; width: 100%; margin-top: 6px; padding: 8px; border: 1px dashed var(--border);
|
|
779
|
+
border-radius: 8px; background: transparent; color: var(--muted); font: inherit; font-size: 12.5px; cursor: pointer;
|
|
780
|
+
}
|
|
781
|
+
.tpl-panel .tpl-save:hover { color: var(--accent); border-color: var(--accent); }
|
|
771
782
|
form.sendbar button {
|
|
772
783
|
padding: 9px 16px; border: 1px solid var(--mine); border-radius: 8px; background: transparent; color: var(--mine);
|
|
773
784
|
font: inherit; font-size: 13.5px; font-weight: 600; cursor: pointer;
|
|
@@ -958,6 +969,33 @@
|
|
|
958
969
|
color: var(--muted); font-size: 13px; padding: 24px 0; text-align: center;
|
|
959
970
|
}
|
|
960
971
|
|
|
972
|
+
/* ----- Cambios (git) ----- */
|
|
973
|
+
.chg-files { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; margin: 0 0 14px; }
|
|
974
|
+
.chg-row { display: grid; grid-template-columns: 34px 1fr auto; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 13px; align-items: center; }
|
|
975
|
+
.chg-row:last-child { border-bottom: 0; }
|
|
976
|
+
.chg-rev {
|
|
977
|
+
background: transparent; border: 1px solid var(--border); color: var(--muted);
|
|
978
|
+
border-radius: 6px; width: 26px; height: 24px; line-height: 1; cursor: pointer; font: inherit; font-size: 12px;
|
|
979
|
+
}
|
|
980
|
+
.chg-rev:hover { color: var(--danger); border-color: var(--danger); }
|
|
981
|
+
.chg-rev:disabled { opacity: .4; cursor: not-allowed; }
|
|
982
|
+
.chg-st { font-weight: 700; text-align: center; font-size: 11px; color: var(--muted); }
|
|
983
|
+
.chg-st.mod { color: var(--warn-text, #d29922); }
|
|
984
|
+
.chg-st.add { color: var(--accent); }
|
|
985
|
+
.chg-st.del { color: var(--danger); }
|
|
986
|
+
.chg-path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
987
|
+
.diff {
|
|
988
|
+
background: var(--code-bg, var(--bg)); border: 1px solid var(--border);
|
|
989
|
+
border-radius: 8px; overflow: auto; padding: 10px 0; margin: 0 0 20px;
|
|
990
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
991
|
+
font-size: 12px; line-height: 1.5;
|
|
992
|
+
}
|
|
993
|
+
.diff-line { white-space: pre; padding: 0 12px; }
|
|
994
|
+
.diff-line.add { background: rgba(63, 185, 80, .12); color: #3fb950; }
|
|
995
|
+
.diff-line.del { background: rgba(248, 81, 73, .12); color: #f85149; }
|
|
996
|
+
.diff-line.hunk { color: var(--accent); }
|
|
997
|
+
.diff-line.head { color: var(--muted); }
|
|
998
|
+
|
|
961
999
|
.view-head { margin-bottom: 12px; }
|
|
962
1000
|
.view-head h2 { margin: 0 0 4px; font-size: 16px; }
|
|
963
1001
|
.view-sub { font-size: 11.5px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
@@ -1159,6 +1197,7 @@
|
|
|
1159
1197
|
<button data-view="search" type="button" title="Buscar">buscar</button>
|
|
1160
1198
|
<button data-view="preview" type="button" title="Vista previa del proyecto (túnel)">preview</button>
|
|
1161
1199
|
<button data-view="procs" type="button" title="Procesos de la sesión">procs</button>
|
|
1200
|
+
<button data-view="changes" type="button" title="Cambios del proyecto (git)">cambios</button>
|
|
1162
1201
|
</div>
|
|
1163
1202
|
<div class="footer">
|
|
1164
1203
|
<div class="row" id="bridgeStatusRow">
|
|
@@ -1221,11 +1260,13 @@
|
|
|
1221
1260
|
</div>
|
|
1222
1261
|
<span class="prompt">❯</span>
|
|
1223
1262
|
<textarea id="input" rows="1" placeholder="escribí un mensaje…" autocomplete="off"></textarea>
|
|
1263
|
+
<button type="button" class="imgbtn" id="btnTpl" title="Plantillas de prompts" aria-label="Plantillas de prompts"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0Z"/></svg></button>
|
|
1224
1264
|
<button type="button" class="imgbtn" id="btnImg" title="Adjuntar imagen (modelos con visión)" aria-label="Adjuntar imagen"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z"/></svg></button>
|
|
1225
1265
|
<button type="button" class="imgbtn" id="btnMic" title="Dictar por voz" aria-label="Dictar por voz"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 1 1 6 0v8.25a3 3 0 0 1-3 3Z"/></svg></button>
|
|
1226
1266
|
<input type="file" id="imgInput" accept="image/png,image/jpeg,image/webp,image/gif" style="display:none">
|
|
1227
1267
|
<button type="submit" id="sendBtn">enviar</button>
|
|
1228
1268
|
<div id="slashPanel" class="slash-panel"></div>
|
|
1269
|
+
<div id="tplPanel" class="slash-panel tpl-panel"></div>
|
|
1229
1270
|
</form>
|
|
1230
1271
|
</div>
|
|
1231
1272
|
|
|
@@ -1235,6 +1276,7 @@
|
|
|
1235
1276
|
<div id="viewHistory" class="view" style="display:none"></div>
|
|
1236
1277
|
<div id="viewPreview" class="view" style="display:none"></div>
|
|
1237
1278
|
<div id="viewProcs" class="view" style="display:none"></div>
|
|
1279
|
+
<div id="viewChanges" class="view" style="display:none"></div>
|
|
1238
1280
|
|
|
1239
1281
|
<footer id="statusbar">
|
|
1240
1282
|
<span class="dot" id="sbDot"></span>
|