primocode 9.8.0-beta.4 → 9.8.0-beta.6

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.
Files changed (52) hide show
  1. package/README.md +15 -4
  2. package/app/ansi.js +55 -0
  3. package/app/ide.css +345 -0
  4. package/app/ide.html +183 -0
  5. package/app/ide.js +639 -0
  6. package/bin/primocode.js +315 -83
  7. package/kits/acolhedor/index.html +130 -0
  8. package/kits/acolhedor/script.js +25 -0
  9. package/kits/acolhedor/style.css +146 -0
  10. package/kits/editorial/index.html +115 -0
  11. package/kits/editorial/script.js +19 -0
  12. package/kits/editorial/style.css +112 -0
  13. package/kits/premium/index.html +83 -0
  14. package/kits/premium/script.js +11 -0
  15. package/kits/premium/style.css +87 -0
  16. package/kits/vitrine/index.html +108 -0
  17. package/kits/vitrine/script.js +20 -0
  18. package/kits/vitrine/style.css +117 -0
  19. package/lib/app.js +41 -6
  20. package/lib/browser.js +14 -1
  21. package/lib/checkpoint-git.js +218 -0
  22. package/lib/checkpoint.js +159 -44
  23. package/lib/comandos-extra.js +481 -0
  24. package/lib/config.js +5 -2
  25. package/lib/ide.js +337 -0
  26. package/lib/kits.js +165 -54
  27. package/lib/skills.js +6 -1
  28. package/lib/ui.js +171 -17
  29. package/package.json +2 -2
  30. package/skills/apresentacao-pptx/SKILL.md +52 -0
  31. package/skills/cartaz-de-arte/LICENSE.txt +202 -0
  32. package/skills/cartaz-de-arte/SKILL.md +47 -0
  33. package/skills/criar-skill/LICENSE.txt +202 -0
  34. package/skills/criar-skill/SKILL.md +49 -0
  35. package/skills/depurar-de-verdade/LICENSE.txt +21 -0
  36. package/skills/depurar-de-verdade/SKILL.md +14 -5
  37. package/skills/documento-word/SKILL.md +57 -0
  38. package/skills/interface-revisada/SKILL.md +47 -0
  39. package/skills/me-ensina/LICENSE.txt +21 -0
  40. package/skills/me-ensina/SKILL.md +27 -0
  41. package/skills/me-entrevista/LICENSE.txt +21 -0
  42. package/skills/me-entrevista/SKILL.md +27 -0
  43. package/skills/pdf-bonito/SKILL.md +48 -0
  44. package/skills/planilha-excel/SKILL.md +47 -0
  45. package/skills/react-sem-dor/SKILL.md +48 -0
  46. package/skills/revisao-antes-de-entregar/LICENSE.txt +21 -0
  47. package/skills/revisao-antes-de-entregar/SKILL.md +6 -1
  48. package/skills/temas-prontos/LICENSE.txt +202 -0
  49. package/skills/temas-prontos/SKILL.md +50 -0
  50. package/skills/testar-o-site/LICENSE.txt +202 -0
  51. package/skills/testar-o-site/SKILL.md +46 -0
  52. package/skills/video-no-remotion/SKILL.md +48 -0
@@ -0,0 +1,218 @@
1
+ /**
2
+ * checkpoint-git.js — a foto do projeto inteiro, num git que a pessoa não vê.
3
+ *
4
+ * O checkpoint por arquivo (lib/checkpoint.js) só enxerga o que passa por
5
+ * write_file/edit_file/multi_edit. Um `rm`, um `mv`, um `sed -i` ou um
6
+ * `npm run build` pelo run_shell mudam o disco sem avisar ninguém — e é
7
+ * justamente aí que "ele apagou meu site" acontece. A saída (a mesma ideia do
8
+ * Cline, escrita aqui do zero) é tirar uma foto do projeto INTEIRO no começo
9
+ * de cada pedido, num repositório git "sombra":
10
+ *
11
+ * ~/.primocode/checkpoints/<projeto>-<hash>/git ← GIT_DIR
12
+ * a pasta do projeto ← GIT_WORK_TREE
13
+ *
14
+ * Por que um git à parte e não o .git do projeto: o repositório da pessoa é
15
+ * dela. Branch, index, stash e HEAD não podem mudar porque o agente tirou uma
16
+ * foto. Por isso TODO comando daqui passa --git-dir e --work-tree explícitos
17
+ * E as variáveis GIT_DIR/GIT_WORK_TREE — nunca há descoberta de repositório
18
+ * a partir da pasta, que acharia o .git da pessoa.
19
+ *
20
+ * Por que a config global fica de fora (GIT_CONFIG_GLOBAL=/dev/null): quem
21
+ * tem commit.gpgsign=true ou um core.hooksPath global veria o PrimoCode pedir
22
+ * a senha da chave GPG ou rodar hooks a cada pedido.
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ const fs = require('fs');
28
+ const os = require('os');
29
+ const path = require('path');
30
+ const { spawnSync } = require('child_process');
31
+
32
+ /** Maior que isso não entra na foto: vídeo exportado, dump de banco, zip.
33
+ A foto é de código e texto; binário grande encheria o disco da pessoa. */
34
+ const LIMITE_BYTES = 20 * 1024 * 1024;
35
+ /** Pasta com mais arquivos novos que isso na primeira foto provavelmente não
36
+ é um projeto (é Documentos, Downloads…). Aí fica o checkpoint por arquivo. */
37
+ const MAX_ARQUIVOS = 20000;
38
+ /** O pedido não pode ficar parado esperando a foto. */
39
+ const TEMPO_MAXIMO_MS = 20000;
40
+
41
+ /** O que nunca entra na foto, além do .gitignore do próprio projeto. São
42
+ coisas que se regeneram (npm install, build) ou que não são da pessoa. */
43
+ const IGNORAR_SEMPRE = [
44
+ 'node_modules/', '.git/', 'dist/', 'build/', '.next/', '.nuxt/', '.turbo/',
45
+ '.cache/', 'coverage/', '__pycache__/', '.venv/', 'venv/', '*.log', '.DS_Store',
46
+ ];
47
+
48
+ let _temGit = null;
49
+ /** Detectado uma vez por processo; o teste zera com _reiniciar(). */
50
+ function temGit() {
51
+ if (process.env.PRIMOCODE_SEM_GIT === '1') return false;
52
+ if (_temGit === null) {
53
+ try {
54
+ const r = spawnSync('git', ['--version'], { encoding: 'utf8', timeout: 5000, windowsHide: true });
55
+ _temGit = r.status === 0 && /git version/.test(r.stdout || '');
56
+ } catch { _temGit = false; }
57
+ }
58
+ return _temGit;
59
+ }
60
+ function _reiniciar() { _temGit = null; }
61
+
62
+ /** O ambiente limpo: nenhuma variável GIT_* herdada (um hook que chamou o
63
+ PrimoCode deixaria GIT_INDEX_FILE apontando para o index da pessoa). */
64
+ function ambiente(gitDir, workTree) {
65
+ const env = {};
66
+ for (const [k, v] of Object.entries(process.env)) if (!/^GIT_/.test(k)) env[k] = v;
67
+ env.GIT_DIR = gitDir;
68
+ env.GIT_WORK_TREE = workTree;
69
+ env.GIT_CONFIG_NOSYSTEM = '1';
70
+ env.GIT_CONFIG_GLOBAL = os.devNull;
71
+ env.GIT_TERMINAL_PROMPT = '0';
72
+ env.GIT_OPTIONAL_LOCKS = '0';
73
+ return env;
74
+ }
75
+
76
+ function git(gitDir, workTree, args, { input } = {}) {
77
+ const r = spawnSync('git', [
78
+ `--git-dir=${gitDir}`, `--work-tree=${workTree}`, '--literal-pathspecs',
79
+ '-c', 'core.autocrlf=false', '-c', 'core.quotepath=false', '-c', 'core.fsmonitor=false',
80
+ '-c', 'commit.gpgsign=false', '-c', `core.hooksPath=${path.join(gitDir, 'sem-hooks')}`,
81
+ '-c', 'user.name=PrimoCode', '-c', 'user.email=desfazer@primocode.local',
82
+ '-c', 'advice.addEmbeddedRepo=false', '-c', 'core.safecrlf=false',
83
+ ...args,
84
+ ], { cwd: workTree, env: ambiente(gitDir, workTree), encoding: 'utf8', input,
85
+ timeout: TEMPO_MAXIMO_MS, maxBuffer: 64 * 1024 * 1024, windowsHide: true });
86
+ if (r.error) throw r.error;
87
+ if (r.status !== 0) throw new Error((r.stderr || '').trim() || `git ${args[0]} saiu com ${r.status}`);
88
+ return r.stdout;
89
+ }
90
+
91
+ /** Um processo morto no meio (timeout, Ctrl+C) deixa o index.lock para trás,
92
+ e dali em diante TODA foto falharia. Lock velho é lixo: sai. */
93
+ function destravar(gitDir) {
94
+ const lock = path.join(gitDir, 'index.lock');
95
+ try { if (Date.now() - fs.statSync(lock).mtimeMs > 60000) fs.rmSync(lock, { force: true }); } catch {}
96
+ }
97
+
98
+ /** Cria o repositório sombra na primeira vez. Devolve false quando a pasta
99
+ não serve para foto (grande demais). */
100
+ function preparar(gitDir, workTree) {
101
+ if (fs.existsSync(path.join(gitDir, 'HEAD'))) return true;
102
+ fs.mkdirSync(gitDir, { recursive: true });
103
+ git(gitDir, workTree, ['init', '-q']);
104
+ fs.mkdirSync(path.join(gitDir, 'info'), { recursive: true });
105
+ fs.writeFileSync(path.join(gitDir, 'info', 'exclude'),
106
+ '# o que o /desfazer nunca fotografa (o .gitignore do projeto vale também)\n'
107
+ + IGNORAR_SEMPRE.join('\n') + '\n');
108
+ // Os hooks de exemplo do template não rodam, mas não precisam ficar lá.
109
+ fs.rmSync(path.join(gitDir, 'hooks'), { recursive: true, force: true });
110
+ return true;
111
+ }
112
+
113
+ /** Arquivo novo grande demais vai para o exclude ANTES do add: o git não
114
+ sabe ignorar por tamanho, então listamos os não rastreados e medimos. */
115
+ function excluirGrandes(gitDir, workTree, { primeira } = {}) {
116
+ const novos = git(gitDir, workTree, ['ls-files', '-z', '--others', '--exclude-standard']).split('\0').filter(Boolean);
117
+ if (primeira && novos.length > MAX_ARQUIVOS) return false;
118
+ const grandes = [];
119
+ for (const rel of novos) {
120
+ try { if (fs.statSync(path.join(workTree, rel)).size > LIMITE_BYTES) grandes.push(rel); } catch {}
121
+ }
122
+ if (grandes.length) {
123
+ // Âncora com "/" e caracteres especiais escapados: o padrão pega só ESTE arquivo.
124
+ const linhas = grandes.map((r) => '/' + r.replace(/([\\*?[\]!# ])/g, '\\$1'));
125
+ fs.appendFileSync(path.join(gitDir, 'info', 'exclude'), linhas.join('\n') + '\n');
126
+ }
127
+ return true;
128
+ }
129
+
130
+ /** Põe o estado atual do disco no index da sombra (só no dela). */
131
+ function registrarEstado(gitDir, workTree, opcoes) {
132
+ destravar(gitDir);
133
+ if (excluirGrandes(gitDir, workTree, opcoes) === false) return false;
134
+ git(gitDir, workTree, ['add', '-A', '--ignore-errors', '--', '.']);
135
+ return true;
136
+ }
137
+
138
+ /** A foto do começo do pedido. Devolve o sha, ou null quando não deu (sem
139
+ git, pasta grande demais, erro) — quem chama cai no checkpoint por arquivo. */
140
+ function fotografar(gitDir, workTree, mensagem) {
141
+ const primeira = !fs.existsSync(path.join(gitDir, 'HEAD'));
142
+ if (!preparar(gitDir, workTree)) return null;
143
+ if (!registrarEstado(gitDir, workTree, { primeira })) {
144
+ // Não é projeto: a sombra criada agora não serve e não pode ficar.
145
+ if (primeira) fs.rmSync(gitDir, { recursive: true, force: true });
146
+ return null;
147
+ }
148
+ git(gitDir, workTree, ['commit', '-q', '--allow-empty', '--no-verify', '-m', String(mensagem || 'pedido').slice(0, 500) || 'pedido']);
149
+ return git(gitDir, workTree, ['rev-parse', 'HEAD']).trim();
150
+ }
151
+
152
+ /** O que mudou da foto `sha` até o disco de agora: [{ rel, status }] com
153
+ status A (nasceu depois), M (mudou) ou D (sumiu). Renomear aparece como
154
+ D + A de propósito (--no-renames): desfazer é recriar um e apagar o outro. */
155
+ function mudancas(gitDir, workTree, sha) {
156
+ registrarEstado(gitDir, workTree);
157
+ const saida = git(gitDir, workTree, ['diff', '--cached', '--no-renames', '--name-status', '-z', sha, '--']);
158
+ const partes = saida.split('\0').filter(Boolean);
159
+ const lista = [];
160
+ for (let i = 0; i + 1 < partes.length; i += 2) {
161
+ const status = partes[i][0] === 'T' ? 'M' : partes[i][0];
162
+ lista.push({ rel: partes[i + 1], status });
163
+ }
164
+ return lista;
165
+ }
166
+
167
+ /** Tira pastas que ficaram vazias depois de apagar o que nasceu no pedido
168
+ (quem criou `css/novo.css` espera que `css/` suma junto). Para na raiz. */
169
+ function podarVazias(workTree, rel) {
170
+ let d = path.dirname(path.join(workTree, rel));
171
+ const raiz = path.resolve(workTree);
172
+ while (d.startsWith(raiz + path.sep)) {
173
+ try { if (fs.readdirSync(d).length) break; fs.rmdirSync(d); } catch { break; }
174
+ d = path.dirname(d);
175
+ }
176
+ }
177
+
178
+ /** Volta o disco para a foto `sha`: o que mudou ou sumiu volta, o que nasceu
179
+ depois sai. Só se apaga o que a sombra VIU nascer — arquivo ignorado
180
+ (node_modules, .gitignore, grande demais) nunca entra no index, então nunca
181
+ aparece como A, então nunca é apagado. */
182
+ function restaurar(gitDir, workTree, sha) {
183
+ const feitos = [];
184
+ const lista = mudancas(gitDir, workTree, sha);
185
+ const statusDe = new Map(lista.map((m) => [m.rel, m.status]));
186
+ const voltar = lista.filter((m) => m.status !== 'A').map((m) => m.rel);
187
+ // Em lotes: um projeto com milhares de mudanças estouraria a linha de comando.
188
+ for (let i = 0; i < voltar.length; i += 200) {
189
+ const lote = voltar.slice(i, i + 200);
190
+ try {
191
+ git(gitDir, workTree, ['checkout', sha, '--', ...lote]);
192
+ for (const rel of lote) feitos.push({ rel, acao: statusDe.get(rel) === 'D' ? 'recriado' : 'restaurado' });
193
+ } catch (e) { for (const rel of lote) feitos.push({ rel, acao: 'falhou', erro: e.message }); }
194
+ }
195
+ for (const m of lista.filter((x) => x.status === 'A')) {
196
+ try {
197
+ fs.rmSync(path.join(workTree, m.rel), { force: true });
198
+ podarVazias(workTree, m.rel);
199
+ feitos.push({ rel: m.rel, acao: 'apagado' });
200
+ } catch (e) { feitos.push({ rel: m.rel, acao: 'falhou', erro: e.message }); }
201
+ }
202
+ // O index da sombra volta a espelhar o disco, para a próxima comparação.
203
+ try { registrarEstado(gitDir, workTree); } catch {}
204
+ return feitos;
205
+ }
206
+
207
+ /** O arquivo está fora da foto (ignorado pelas regras)? Aí o checkpoint por
208
+ arquivo cobre — editar `dist/app.js` continua tendo volta. */
209
+ function ignorado(gitDir, workTree, abs) {
210
+ const rel = path.relative(workTree, abs);
211
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return true;
212
+ const r = spawnSync('git', [`--git-dir=${gitDir}`, `--work-tree=${workTree}`, 'check-ignore', '-q', '--no-index', '--', rel],
213
+ { cwd: workTree, env: ambiente(gitDir, workTree), timeout: 5000, windowsHide: true });
214
+ return r.status === 0;
215
+ }
216
+
217
+ module.exports = { IGNORAR_SEMPRE, LIMITE_BYTES, MAX_ARQUIVOS, temGit, _reiniciar,
218
+ fotografar, mudancas, restaurar, ignorado };
package/lib/checkpoint.js CHANGED
@@ -13,9 +13,18 @@
13
13
  * agente só avisa `antesDeMudar(arquivo, pasta)` — o estado fica aqui, no
14
14
  * módulo, e o subagente (mesmo processo) cai no mesmo checkpoint.
15
15
  *
16
- * Onde fica: ~/.primocode/checkpoints/<projeto>-<hash>/<id>/
17
- * manifest.json o pedido, a hora e cada arquivo (existia ou não)
18
- * 0, 1, 2… a cópia original de cada arquivo que existia
16
+ * Dois jeitos de guardar, o melhor que a máquina permitir:
17
+ * COM git — no `iniciar`, uma foto do projeto INTEIRO num git sombra
18
+ * (lib/checkpoint-git.js). Cobre o que o run_shell apaga, move ou
19
+ * gera, que a tool de arquivo nunca vê.
20
+ * SEM git — o de antes: a cópia de cada arquivo que as tools de arquivo
21
+ * vão mexer. Também cobre, com git, o que a foto não pega
22
+ * (arquivo ignorado, como dist/, e pasta fora do projeto).
23
+ *
24
+ * Onde fica: ~/.primocode/checkpoints/<projeto>-<hash>/
25
+ * git/ o repositório sombra (só com git)
26
+ * <id>/manifest.json o pedido, a hora, o sha da foto e os arquivos
27
+ * <id>/0, 1, 2… a cópia original de cada arquivo guardado um a um
19
28
  * Só os últimos MAXIMO por projeto: é rede de segurança, não arquivo morto.
20
29
  */
21
30
 
@@ -25,13 +34,20 @@ const fs = require('fs');
25
34
  const os = require('os');
26
35
  const path = require('path');
27
36
  const crypto = require('crypto');
37
+ const foto = require('./checkpoint-git');
28
38
 
29
39
  const MAXIMO = 20;
30
40
  /** As tools de arquivo que mudam o disco. run_shell fica de fora: não dá para
31
- saber, antes de rodar, que arquivos um comando vai tocar. */
41
+ saber, antes de rodar, que arquivos um comando vai tocar — quem cobre o
42
+ run_shell é a foto do projeto inteiro, tirada antes do pedido. */
32
43
  const MUDAM_ARQUIVO = new Set(['write_file', 'edit_file', 'multi_edit']);
33
44
 
34
- let atual = null; // o pedido em andamento: { pedido, dir, manifest }
45
+ /* O pedido em andamento:
46
+ { pedido, git: { projectDir, gitDir, dir, manifest } | null,
47
+ arq: { dir, manifest } | null }
48
+ `git` é o checkpoint com foto; `arq` é o por arquivo, para o que cai fora
49
+ da foto (ou para tudo, quando não há git). */
50
+ let atual = null;
35
51
 
36
52
  /* os.homedir() lido na hora, não no require: o teste troca o HOME. */
37
53
  function raiz() { return path.join(os.homedir(), '.primocode', 'checkpoints'); }
@@ -45,46 +61,94 @@ function pastaDoProjeto(projectDir) {
45
61
  return path.join(raiz(), `${nome}-${hash}`);
46
62
  }
47
63
 
48
- /** Começa o checkpoint de um pedido. Nada vai ao disco ainda: pedido que não
49
- mexe em arquivo (uma pergunta, uma pesquisa) não deixa checkpoint vazio. */
50
- function iniciar(pedido) {
51
- atual = { pedido: String(pedido || '').replace(/\s+/g, ' ').trim(), dir: null, manifest: null };
64
+ /** Pasta que merece foto. A casa da pessoa e o ~/primocode sem projeto
65
+ ficam de fora: fotografar o disco inteiro a cada pedido seria lento,
66
+ encheria o ~/.primocode e desfaria coisas que não são do pedido. */
67
+ function podeFotografar(projectDir) {
68
+ if (!projectDir || !foto.temGit()) return false;
69
+ const abs = path.resolve(projectDir);
70
+ const casa = path.resolve(os.homedir());
71
+ if (abs === casa || abs === path.join(casa, 'primocode') || abs === path.parse(abs).root) return false;
72
+ try { return fs.statSync(abs).isDirectory(); } catch { return false; }
73
+ }
74
+
75
+ /** Uma pasta de checkpoint nova. O id é sempre maior que o anterior: a
76
+ ordem dos ids É a ordem dos pedidos. */
77
+ function novoCheckpoint(projectDir, extra) {
78
+ const base = pastaDoProjeto(projectDir);
79
+ fs.mkdirSync(base, { recursive: true });
80
+ const ultimo = fs.readdirSync(base).filter((n) => /^\d+$/.test(n)).reduce((m, n) => Math.max(m, Number(n)), 0);
81
+ const dir = path.join(base, String(Math.max(Date.now(), ultimo + 1)));
82
+ fs.mkdirSync(dir);
83
+ const manifest = { pedido: atual.pedido, quando: new Date().toISOString(),
84
+ projectDir: path.resolve(projectDir), ...extra, arquivos: [] };
85
+ const cp = { dir, manifest };
86
+ gravarManifest(cp);
87
+ podar(base);
88
+ return cp;
89
+ }
90
+
91
+ function gravarManifest(cp) {
92
+ fs.writeFileSync(path.join(cp.dir, 'manifest.json'), JSON.stringify(cp.manifest, null, 2));
52
93
  }
53
94
 
54
- function encerrar() { atual = null; }
95
+ /** Começa o checkpoint de um pedido. Com git e uma pasta de projeto, a foto
96
+ é tirada AGORA — antes de qualquer tool rodar. Sem git (ou sem pasta),
97
+ nada vai ao disco ainda: pedido que não mexe em arquivo não deixa rastro. */
98
+ function iniciar(pedido, projectDir) {
99
+ atual = { pedido: String(pedido || '').replace(/\s+/g, ' ').trim(), git: null, arq: null };
100
+ if (!podeFotografar(projectDir)) return;
101
+ try {
102
+ const abs = path.resolve(projectDir);
103
+ const gitDir = path.join(pastaDoProjeto(abs), 'git');
104
+ const sha = foto.fotografar(gitDir, abs, atual.pedido);
105
+ if (sha) atual.git = { projectDir: abs, gitDir, ...novoCheckpoint(abs, { git: { sha } }) };
106
+ } catch { /* sem foto, o checkpoint por arquivo segura o pedido */ }
107
+ }
55
108
 
56
- function gravarManifest() {
57
- fs.writeFileSync(path.join(atual.dir, 'manifest.json'), JSON.stringify(atual.manifest, null, 2));
109
+ /** Fecha o pedido. Com foto, anota o que mudou (é o que o `/desfazer lista`
110
+ mostra) — e, se nada mudou, o checkpoint some: uma pergunta não pode
111
+ ocupar o lugar de um pedido que o /desfazer deveria voltar. */
112
+ function encerrar() {
113
+ const g = atual && atual.git;
114
+ atual = null;
115
+ if (!g) return;
116
+ try {
117
+ const m = foto.mudancas(g.gitDir, g.projectDir, g.manifest.git.sha);
118
+ if (!m.length && !g.manifest.arquivos.length) { fs.rmSync(g.dir, { recursive: true, force: true }); return; }
119
+ g.manifest.mudancas = m;
120
+ gravarManifest(g);
121
+ } catch { /* sem a conta, a lista calcula na hora */ }
58
122
  }
59
123
 
60
- /** Chamado pelo loop ANTES de uma tool de arquivo rodar. Guarda o original só
61
- na primeira vez que o arquivo é tocado no pedido — a segunda edição do
62
- mesmo arquivo não pode sobrescrever o original com a versão já editada. */
124
+ /** Guarda o original de um arquivo no checkpoint `cp`, uma vez só: a segunda
125
+ edição do mesmo arquivo não pode sobrescrever o original já editado. */
126
+ function guardarOriginal(cp, abs) {
127
+ if (cp.manifest.arquivos.some((a) => a.caminho === abs)) return;
128
+ const existia = fs.existsSync(abs) && fs.statSync(abs).isFile();
129
+ const item = { caminho: abs, existia };
130
+ if (existia) {
131
+ item.copia = String(cp.manifest.arquivos.length);
132
+ fs.copyFileSync(abs, path.join(cp.dir, item.copia));
133
+ }
134
+ cp.manifest.arquivos.push(item);
135
+ gravarManifest(cp);
136
+ }
137
+
138
+ /** Chamado pelo loop ANTES de uma tool de arquivo rodar. Se a foto do pedido
139
+ já cobre o arquivo, não há o que fazer; senão, guarda o original. */
63
140
  function antesDeMudar(caminhoAbs, projectDir) {
64
141
  if (!atual || !caminhoAbs) return;
65
142
  try {
66
- if (!atual.dir) {
67
- const base = pastaDoProjeto(projectDir);
68
- fs.mkdirSync(base, { recursive: true });
69
- // Sempre maior que o anterior: a ordem dos ids É a ordem dos pedidos.
70
- const ultimo = fs.readdirSync(base).filter((n) => /^\d+$/.test(n)).reduce((m, n) => Math.max(m, Number(n)), 0);
71
- const id = String(Math.max(Date.now(), ultimo + 1));
72
- atual.dir = path.join(base, id);
73
- fs.mkdirSync(atual.dir);
74
- atual.manifest = { pedido: atual.pedido, quando: new Date().toISOString(),
75
- projectDir: path.resolve(projectDir), arquivos: [] };
76
- podar(base);
77
- }
78
143
  const abs = path.resolve(caminhoAbs);
79
- if (atual.manifest.arquivos.some((a) => a.caminho === abs)) return;
80
- const existia = fs.existsSync(abs) && fs.statSync(abs).isFile();
81
- const item = { caminho: abs, existia };
82
- if (existia) {
83
- item.copia = String(atual.manifest.arquivos.length);
84
- fs.copyFileSync(abs, path.join(atual.dir, item.copia));
144
+ const g = atual.git;
145
+ if (g && (abs + path.sep).startsWith(g.projectDir + path.sep)) {
146
+ if (!foto.ignorado(g.gitDir, g.projectDir, abs)) return;
147
+ guardarOriginal(g, abs); // dist/, .gitignore: fora da foto, mas com volta
148
+ return;
85
149
  }
86
- atual.manifest.arquivos.push(item);
87
- gravarManifest();
150
+ if (!atual.arq) atual.arq = novoCheckpoint(projectDir || path.dirname(abs), {});
151
+ guardarOriginal(atual.arq, abs);
88
152
  } catch { /* checkpoint nunca derruba o trabalho do agente */ }
89
153
  }
90
154
 
@@ -93,7 +157,23 @@ function podar(base) {
93
157
  for (const velho of ids.slice(MAXIMO)) fs.rmSync(path.join(base, velho), { recursive: true, force: true });
94
158
  }
95
159
 
96
- /** Os checkpoints do projeto, o mais novo primeiro. */
160
+ const ACAO_DA_FOTO = { A: 'criado', M: 'mudou', D: 'apagado' };
161
+
162
+ /** O que o pedido mudou, num formato só para os dois jeitos de guardar. */
163
+ function mudancasDe(cp) {
164
+ const vistos = new Map();
165
+ let daFoto = cp.mudancas;
166
+ if (cp.git && !daFoto) {
167
+ // Pedido interrompido (ou o que está rodando): a conta é feita agora.
168
+ try { daFoto = foto.mudancas(path.join(path.dirname(cp.dir), 'git'), cp.projectDir, cp.git.sha); } catch { daFoto = []; }
169
+ }
170
+ for (const m of daFoto || []) vistos.set(path.join(cp.projectDir, m.rel), ACAO_DA_FOTO[m.status] || 'mudou');
171
+ for (const a of cp.arquivos || []) if (!vistos.has(a.caminho)) vistos.set(a.caminho, a.existia ? 'mudou' : 'criado');
172
+ return [...vistos].map(([caminho, acao]) => ({ caminho, acao }));
173
+ }
174
+
175
+ /** Os checkpoints do projeto, o mais novo primeiro. Cada um traz `mudou`:
176
+ os arquivos que o pedido criou, mudou ou apagou. */
97
177
  function lista(projectDir) {
98
178
  const base = pastaDoProjeto(projectDir);
99
179
  let ids = [];
@@ -101,34 +181,69 @@ function lista(projectDir) {
101
181
  return ids.sort((a, b) => Number(b) - Number(a)).map((id) => {
102
182
  try {
103
183
  const m = JSON.parse(fs.readFileSync(path.join(base, id, 'manifest.json'), 'utf8'));
104
- return { id, dir: path.join(base, id), ...m };
184
+ const cp = { id, dir: path.join(base, id), ...m };
185
+ cp.mudou = mudancasDe(cp);
186
+ return cp;
105
187
  } catch { return null; }
106
188
  }).filter(Boolean);
107
189
  }
108
190
 
191
+ /** O que o /desfazer N vai fazer, para a pessoa ler ANTES de confirmar.
192
+ Com foto, a conta é contra o disco de agora (inclui o que mudou depois do
193
+ pedido, que a restauração também volta). */
194
+ function previa(projectDir, n = 1) {
195
+ const cps = lista(projectDir).slice(0, Math.max(1, n));
196
+ const vistos = new Map();
197
+ const maisVelhoComFoto = [...cps].reverse().find((cp) => cp.git);
198
+ if (maisVelhoComFoto) {
199
+ try {
200
+ for (const m of foto.mudancas(path.join(path.dirname(maisVelhoComFoto.dir), 'git'),
201
+ maisVelhoComFoto.projectDir, maisVelhoComFoto.git.sha)) {
202
+ vistos.set(path.join(maisVelhoComFoto.projectDir, m.rel), m.status === 'A' ? 'apaga (é novo)' : m.status === 'D' ? 'recria (tinha sumido)' : 'volta ao original');
203
+ }
204
+ } catch {}
205
+ }
206
+ for (const cp of cps) for (const a of cp.arquivos || []) {
207
+ if (!vistos.has(a.caminho)) vistos.set(a.caminho, a.existia ? 'volta ao original' : 'apaga (era novo)');
208
+ }
209
+ return [...vistos].map(([caminho, acao]) => ({ caminho, acao }));
210
+ }
211
+
109
212
  /** Desfaz os N pedidos mais recentes, do mais novo para o mais velho — assim,
110
213
  se dois pedidos mexeram no mesmo arquivo, o que sobra é o original do mais
111
214
  antigo. Cada checkpoint desfeito é apagado: o próximo /desfazer volta mais
112
215
  um pedido, em vez de repetir o mesmo. */
113
216
  function desfazer(projectDir, n = 1) {
114
- const feitos = [];
217
+ const feitos = new Map(); // um arquivo, uma linha: vale a última ação
218
+ const anotar = (caminho, acao, erro) => { feitos.delete(caminho); feitos.set(caminho, erro ? { caminho, acao, erro } : { caminho, acao }); };
115
219
  for (const cp of lista(projectDir).slice(0, Math.max(1, n))) {
220
+ if (cp.git) {
221
+ try {
222
+ for (const f of foto.restaurar(path.join(path.dirname(cp.dir), 'git'), cp.projectDir, cp.git.sha)) {
223
+ anotar(path.join(cp.projectDir, f.rel), f.acao, f.erro);
224
+ }
225
+ } catch (e) { anotar(cp.projectDir, 'falhou', e.message); }
226
+ }
116
227
  for (const a of cp.arquivos) {
117
228
  try {
118
229
  if (a.existia) {
119
230
  fs.mkdirSync(path.dirname(a.caminho), { recursive: true });
120
231
  fs.copyFileSync(path.join(cp.dir, a.copia), a.caminho);
121
- feitos.push({ caminho: a.caminho, acao: 'restaurado' });
232
+ anotar(a.caminho, 'restaurado');
122
233
  } else {
123
234
  fs.rmSync(a.caminho, { force: true });
124
- feitos.push({ caminho: a.caminho, acao: 'apagado' });
235
+ anotar(a.caminho, 'apagado');
125
236
  }
126
- } catch (e) { feitos.push({ caminho: a.caminho, acao: 'falhou', erro: e.message }); }
237
+ } catch (e) { anotar(a.caminho, 'falhou', e.message); }
127
238
  }
128
239
  fs.rmSync(cp.dir, { recursive: true, force: true });
129
- if (atual && atual.dir === cp.dir) atual = null;
240
+ if (atual && atual.git && atual.git.dir === cp.dir) atual.git = null;
241
+ if (atual && atual.arq && atual.arq.dir === cp.dir) atual.arq = null;
130
242
  }
131
- return feitos;
243
+ return [...feitos.values()];
132
244
  }
133
245
 
134
- module.exports = { MAXIMO, MUDAM_ARQUIVO, iniciar, encerrar, antesDeMudar, lista, desfazer, pastaDoProjeto };
246
+ /** Qual jeito de guardar vale nesta pasta — o /desfazer lista conta à pessoa. */
247
+ function modo(projectDir) { return podeFotografar(projectDir) ? 'foto' : 'arquivo'; }
248
+
249
+ module.exports = { MAXIMO, MUDAM_ARQUIVO, iniciar, encerrar, antesDeMudar, lista, previa, desfazer, modo, pastaDoProjeto };