From a7587235c6ced2e4b6a902367cda1f3a43e4fbe8 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Tue, 18 Aug 2026 22:48:11 +0200 Subject: [PATCH] Panneau Fichiers : toute la discussion en une archive .zip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bouton « .zip » dans le pied du panneau : la discussion entière — dossiers et sous-dossiers compris — part en une archive, nommée d'après le titre de la discussion. Même danse en trois temps que le téléchargement de fichier (meta → brut, ou tranches base64 derrière le tunnel chiffré), liens symboliques ignorés, parcours borné comme le calcul de taille du panneau. Le scope « hors discussion » a son propre zip, sans le dossier des discussions. Co-Authored-By: Claude Opus 5 --- internal/loki/ui/index.html | 55 +++++++ internal/loki/ui/src/js/15-files.js | 55 +++++++ internal/loki/web_files_zip.go | 240 ++++++++++++++++++++++++++++ internal/loki/web_files_zip_test.go | 96 +++++++++++ internal/loki/web_server.go | 1 + 5 files changed, 447 insertions(+) create mode 100644 internal/loki/web_files_zip.go create mode 100644 internal/loki/web_files_zip_test.go diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 6c4ac64..addb86d 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -7210,6 +7210,16 @@ function renderFoot(foot, r){ n.textContent = r.count + (r.count > 1 ? ' fichiers · ' : ' fichier · ') + fmtSize(r.total); foot.appendChild(n); foot.title = r.root || ''; + // Tout emporter d'un coup : la discussion entière (dossiers et sous-dossiers + // compris) en une archive .zip. Absent quand il n'y a rien à archiver. + if(r.count > 0){ + const z = document.createElement('button'); + z.className = 'files-scope'; + z.textContent = '.zip'; + z.title = 'tout télécharger en une archive .zip'; + z.onclick = ()=>downloadFilesZip(z); + foot.appendChild(z); + } if(FILES_SCOPE !== 'legacy' && !r.legacy) return; const b = document.createElement('button'); b.className = 'files-scope'; @@ -7323,6 +7333,51 @@ async function removeFile(e){ loadFiles(); } +// Télécharge TOUT le contenu affiché (discussion ou hors-discussion) en .zip. +// Même danse que downloadWorkspaceFile (14-attach.js) : la fiche meta d'abord — +// c'est elle qui CONSTRUIT l'archive côté serveur — puis le brut en direct, ou +// les tranches base64 derrière le tunnel chiffré. +async function downloadFilesZip(btn){ + if(btn){ btn.disabled = true; btn.textContent = '…'; } + try{ + const m = await jfetch('/api/chat/files/zip?meta=1&scope='+encodeURIComponent(FILES_SCOPE)); + const meta = await m.json().catch(()=>({})); + if(!m.ok || !meta.ok){ toast('archive impossible : '+(meta.error||('HTTP '+m.status))); return; } + let blob; + if(meta.e2e){ + blob = await fetchZipB64(meta.size||0); + } else { + const r = await jfetch('/api/chat/files/zip?scope='+encodeURIComponent(FILES_SCOPE)); + if(!r.ok){ let msg='HTTP '+r.status; try{ msg=(await r.json()).error||msg; }catch(_){} toast('archive impossible : '+msg); return; } + blob = await r.blob(); + } + const u = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = u; a.download = meta.name || 'loki-fichiers.zip'; + document.body.appendChild(a); a.click(); a.remove(); + setTimeout(()=>URL.revokeObjectURL(u), 10000); + toast(meta.files + ' fichier'+(meta.files>1?'s':'')+' archivé'+(meta.files>1?'s':'')); + }catch(e){ toast('archive impossible : '+((e&&e.message)||'erreur')); } + finally{ if(btn){ btn.disabled = false; btn.textContent = '.zip'; } } +} + +// Tranches base64 du zip (tunnel chiffré) — cousin de fetchFileB64. +async function fetchZipB64(size){ + const parts = []; + let off = 0; + do{ + const r = await jfetch('/api/chat/files/zip?b64=1&len='+DL_CHUNK+'&offset='+off+'&scope='+encodeURIComponent(FILES_SCOPE)); + const j = await r.json(); + if(!r.ok || !j.ok) throw new Error(j.error||('HTTP '+r.status)); + const bytes = b64ToBytes(j.data||''); + parts.push(bytes); + if(!bytes.length && !j.eof) throw new Error('transfert interrompu'); + off = (j.offset||0) + bytes.length; + if(j.eof) break; + } while(off < size); + return new Blob(parts, {type:'application/zip'}); +} + // Changement de discussion (bascule, « vider »), signalé par le flux SSE : // le panneau montre les fichiers de la discussion OUVERTE, il doit donc être // redessiné. On revient aussi à sa racine — le sous-dossier affiché était celui diff --git a/internal/loki/ui/src/js/15-files.js b/internal/loki/ui/src/js/15-files.js index a188ea9..87fb15a 100644 --- a/internal/loki/ui/src/js/15-files.js +++ b/internal/loki/ui/src/js/15-files.js @@ -86,6 +86,16 @@ function renderFoot(foot, r){ n.textContent = r.count + (r.count > 1 ? ' fichiers · ' : ' fichier · ') + fmtSize(r.total); foot.appendChild(n); foot.title = r.root || ''; + // Tout emporter d'un coup : la discussion entière (dossiers et sous-dossiers + // compris) en une archive .zip. Absent quand il n'y a rien à archiver. + if(r.count > 0){ + const z = document.createElement('button'); + z.className = 'files-scope'; + z.textContent = '.zip'; + z.title = 'tout télécharger en une archive .zip'; + z.onclick = ()=>downloadFilesZip(z); + foot.appendChild(z); + } if(FILES_SCOPE !== 'legacy' && !r.legacy) return; const b = document.createElement('button'); b.className = 'files-scope'; @@ -199,6 +209,51 @@ async function removeFile(e){ loadFiles(); } +// Télécharge TOUT le contenu affiché (discussion ou hors-discussion) en .zip. +// Même danse que downloadWorkspaceFile (14-attach.js) : la fiche meta d'abord — +// c'est elle qui CONSTRUIT l'archive côté serveur — puis le brut en direct, ou +// les tranches base64 derrière le tunnel chiffré. +async function downloadFilesZip(btn){ + if(btn){ btn.disabled = true; btn.textContent = '…'; } + try{ + const m = await jfetch('/api/chat/files/zip?meta=1&scope='+encodeURIComponent(FILES_SCOPE)); + const meta = await m.json().catch(()=>({})); + if(!m.ok || !meta.ok){ toast('archive impossible : '+(meta.error||('HTTP '+m.status))); return; } + let blob; + if(meta.e2e){ + blob = await fetchZipB64(meta.size||0); + } else { + const r = await jfetch('/api/chat/files/zip?scope='+encodeURIComponent(FILES_SCOPE)); + if(!r.ok){ let msg='HTTP '+r.status; try{ msg=(await r.json()).error||msg; }catch(_){} toast('archive impossible : '+msg); return; } + blob = await r.blob(); + } + const u = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = u; a.download = meta.name || 'loki-fichiers.zip'; + document.body.appendChild(a); a.click(); a.remove(); + setTimeout(()=>URL.revokeObjectURL(u), 10000); + toast(meta.files + ' fichier'+(meta.files>1?'s':'')+' archivé'+(meta.files>1?'s':'')); + }catch(e){ toast('archive impossible : '+((e&&e.message)||'erreur')); } + finally{ if(btn){ btn.disabled = false; btn.textContent = '.zip'; } } +} + +// Tranches base64 du zip (tunnel chiffré) — cousin de fetchFileB64. +async function fetchZipB64(size){ + const parts = []; + let off = 0; + do{ + const r = await jfetch('/api/chat/files/zip?b64=1&len='+DL_CHUNK+'&offset='+off+'&scope='+encodeURIComponent(FILES_SCOPE)); + const j = await r.json(); + if(!r.ok || !j.ok) throw new Error(j.error||('HTTP '+r.status)); + const bytes = b64ToBytes(j.data||''); + parts.push(bytes); + if(!bytes.length && !j.eof) throw new Error('transfert interrompu'); + off = (j.offset||0) + bytes.length; + if(j.eof) break; + } while(off < size); + return new Blob(parts, {type:'application/zip'}); +} + // Changement de discussion (bascule, « vider »), signalé par le flux SSE : // le panneau montre les fichiers de la discussion OUVERTE, il doit donc être // redessiné. On revient aussi à sa racine — le sous-dossier affiché était celui diff --git a/internal/loki/web_files_zip.go b/internal/loki/web_files_zip.go new file mode 100644 index 0000000..3398864 --- /dev/null +++ b/internal/loki/web_files_zip.go @@ -0,0 +1,240 @@ +package loki + +// web_files_zip.go — téléchargement de TOUS les fichiers d'une discussion en +// une archive .zip (bouton du panneau Fichiers). +// +// GET /api/chat/files/zip?meta=1[&scope=legacy] → {ok, name, size, e2e} +// GET /api/chat/files/zip[&scope=legacy] → le zip, brut +// GET /api/chat/files/zip?b64=1&offset=&len= → une tranche base64 +// +// Même danse en trois temps que /api/chat/file (web_upload.go), et pour la +// même raison : à travers le tunnel chiffré, le binaire ne survit pas — seules +// les tranches base64 passent. L'archive est construite dans un fichier +// TEMPORAIRE à l'appel `meta` (le client commence toujours par lui), puis les +// appels suivants la servent telle quelle : la construire à chaque tranche +// donnerait des offsets incohérents si un fichier bouge entre deux tranches. +// +// Les liens symboliques ne sont PAS suivis (même règle que dirSize) et le +// parcours s'arrête à wsWalkLimit entrées : le zip d'une discussion est celui +// de ce que le panneau affiche, pas un aspirateur à disque. + +import ( + "archive/zip" + "encoding/base64" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +// zipPathFor : l'archive temporaire d'une racine donnée. Un nom stable par +// discussion — l'appel meta l'écrase, les tranches la relisent. +func zipPathFor(scope string) string { + id := "legacy" + if strings.TrimSpace(scope) != "legacy" { + id = safeConvID(convEnsureActive()) + if id == "" { + id = "conv" + } + } + return filepath.Join(os.TempDir(), "loki-files-"+id+".zip") +} + +// zipName : le nom proposé au navigateur. +func zipName(scope string) string { + stamp := time.Now().Format("2006-01-02") + if strings.TrimSpace(scope) == "legacy" { + return "loki-hors-discussion-" + stamp + ".zip" + } + // Le titre de la discussion, s'il existe, fait un nom parlant ; l'ID sinon. + id := convEnsureActive() + title := "" + for _, m := range convIndex() { + if m.ID == id { + title = m.Title + break + } + } + slug := slugify(title) + if slug == "" { + slug = id + } + return "loki-" + slug + "-" + stamp + ".zip" +} + +// slugify réduit un titre à un nom de fichier sûr : minuscules, ascii, tirets. +func slugify(s string) string { + var b strings.Builder + for _, r := range strings.ToLower(strings.TrimSpace(s)) { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + b.WriteRune(r) + case r == ' ' || r == '-' || r == '_' || r == '.': + // Pas de tirets en rafale : « Déjà vu » → dj-vu, pas dj---vu. + if out := b.String(); out != "" && !strings.HasSuffix(out, "-") { + b.WriteByte('-') + } + } + if b.Len() >= 40 { + break + } + } + return strings.Trim(b.String(), "-") +} + +// buildZip archive le contenu de root dans dest. Liens symboliques ignorés, +// parcours borné, chemins en avant-slash relatifs à root. En scope +// hors-discussion, le dossier des discussions est exclu (il a son propre zip, +// discussion par discussion). +func buildZip(root, dest string, excludeConvs bool) (files int, err error) { + out, err := os.Create(dest) + if err != nil { + return 0, err + } + defer out.Close() + zw := zip.NewWriter(out) + defer zw.Close() + + var walk func(dir, rel string) error + walk = func(dir, rel string) error { + ents, rerr := os.ReadDir(dir) + if rerr != nil { + return nil // dossier illisible : on archive le reste + } + for _, e := range ents { + if files >= wsWalkLimit { + return nil + } + if e.Type()&os.ModeSymlink != 0 { + continue + } + childRel := e.Name() + if rel != "" { + childRel = rel + "/" + e.Name() + } + if e.IsDir() { + if excludeConvs && rel == "" && e.Name() == convFilesRoot { + continue + } + if werr := walk(filepath.Join(dir, e.Name()), childRel); werr != nil { + return werr + } + continue + } + info, ierr := e.Info() + if ierr != nil { + continue + } + hdr, herr := zip.FileInfoHeader(info) + if herr != nil { + continue + } + hdr.Name = childRel + hdr.Method = zip.Deflate + w, werr := zw.CreateHeader(hdr) + if werr != nil { + return werr + } + f, ferr := os.Open(filepath.Join(dir, e.Name())) + if ferr != nil { + continue + } + _, cerr := io.Copy(w, f) + f.Close() + if cerr != nil { + return cerr + } + files++ + } + return nil + } + if err := walk(root, ""); err != nil { + return files, err + } + return files, nil +} + +// handleChatFilesZip — voir l'en-tête du fichier. +func handleChatFilesZip(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + scope := q.Get("scope") + root, _ := filesRoot(scope) + tmp := zipPathFor(scope) + + if q.Get("meta") != "" { + files, err := buildZip(root, tmp, scope == "legacy") + if err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + if files == 0 { + os.Remove(tmp) + sendJSON(w, 404, map[string]any{"ok": false, "error": "aucun fichier à archiver"}) + return + } + st, err := os.Stat(tmp) + if err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{ + "ok": true, "name": zipName(scope), "size": st.Size(), "files": files, + "e2e": r.Header.Get(e2eInnerHeader) != "", + }) + return + } + + st, err := os.Stat(tmp) + if err != nil { + sendJSON(w, 404, map[string]any{"ok": false, "error": "archive absente — redemande la fiche (meta=1) d'abord"}) + return + } + if q.Get("b64") != "" { + off, _ := strconv.ParseInt(q.Get("offset"), 10, 64) + length, _ := strconv.ParseInt(q.Get("len"), 10, 64) + if length <= 0 || length > downloadChunkMax { + length = downloadChunkMax + } + if off < 0 || off > st.Size() { + sendJSON(w, 400, map[string]any{"ok": false, "error": "position hors du fichier"}) + return + } + if off+length > st.Size() { + length = st.Size() - off + } + f, ferr := os.Open(tmp) + if ferr != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": ferr.Error()}) + return + } + defer f.Close() + buf := make([]byte, length) + // Même lecture que /api/chat/file : ReadAt remplit tout le tampon, et + // io.EOF sur la dernière tranche est normal. + n, rerr := f.ReadAt(buf, off) + if rerr != nil && rerr != io.EOF { + sendJSON(w, 500, map[string]any{"ok": false, "error": rerr.Error()}) + return + } + sendJSON(w, 200, map[string]any{ + "ok": true, "name": zipName(scope), "size": st.Size(), "offset": off, + "data": base64.StdEncoding.EncodeToString(buf[:n]), + "eof": off+int64(n) >= st.Size(), + }) + return + } + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", zipName(scope))) + w.Header().Set("Content-Length", strconv.FormatInt(st.Size(), 10)) + f, err := os.Open(tmp) + if err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + defer f.Close() + io.Copy(w, f) +} diff --git a/internal/loki/web_files_zip_test.go b/internal/loki/web_files_zip_test.go new file mode 100644 index 0000000..c1fbb36 --- /dev/null +++ b/internal/loki/web_files_zip_test.go @@ -0,0 +1,96 @@ +package loki + +import ( + "archive/zip" + "os" + "path/filepath" + "testing" +) + +// L'archive embarque TOUT l'arbre — fichiers, dossiers, sous-dossiers — avec +// des chemins relatifs en avant-slash. +func TestZipToutLArbre(t *testing.T) { + ws := withWorkspace(t) + writeFile(t, filepath.Join(ws, "racine.txt"), "a") + writeFile(t, filepath.Join(ws, "docs", "note.md"), "b") + writeFile(t, filepath.Join(ws, "src", "sub", "deep", "main.go"), "c") + + dest := filepath.Join(t.TempDir(), "out.zip") + n, err := buildZip(ws, dest, false) + if err != nil { + t.Fatal(err) + } + if n != 3 { + t.Fatalf("%d fichiers archivés, attendu 3", n) + } + zr, err := zip.OpenReader(dest) + if err != nil { + t.Fatal(err) + } + defer zr.Close() + got := map[string]bool{} + for _, f := range zr.File { + got[f.Name] = true + } + for _, want := range []string{"racine.txt", "docs/note.md", "src/sub/deep/main.go"} { + if !got[want] { + t.Errorf("%s absent de l'archive (contenu : %v)", want, got) + } + } +} + +// Le scope hors-discussion n'aspire pas le dossier des discussions. +func TestZipLegacyExclutDiscussions(t *testing.T) { + ws := withWorkspace(t) + root := agentWorkspace() + writeFile(t, filepath.Join(root, "orphelin.txt"), "x") + writeFile(t, filepath.Join(ws, "dans-discussion.txt"), "y") // sous discussions/ + + dest := filepath.Join(t.TempDir(), "out.zip") + n, err := buildZip(root, dest, true) + if err != nil { + t.Fatal(err) + } + zr, err := zip.OpenReader(dest) + if err != nil { + t.Fatal(err) + } + defer zr.Close() + for _, f := range zr.File { + if f.Name == "discussions/"+filepath.Base(ws)+"/dans-discussion.txt" || filepath.Dir(f.Name) != "." && f.Name[:12] == "discussions/" { + t.Errorf("le dossier des discussions a fuité dans l'archive : %s", f.Name) + } + } + if n < 1 { + t.Fatal("orphelin.txt manquant") + } +} + +// Une discussion vide : 404 propre, pas d'archive fantôme. +func TestZipVide(t *testing.T) { + ws := withWorkspace(t) + dest := filepath.Join(t.TempDir(), "out.zip") + n, err := buildZip(ws, dest, false) + if err != nil { + t.Fatal(err) + } + if n != 0 { + t.Fatalf("%d fichiers dans une discussion vide", n) + } + _ = os.Remove(dest) +} + +// slugify : titres réels → noms de fichiers sûrs. +func TestSlugify(t *testing.T) { + cases := map[string]string{ + "Corrige le bug d'auth": "corrige-le-bug-dauth", + " Déjà vu ! ": "dj-vu", + "": "", + "UPPER case.and.dots": "upper-case-and-dots", + } + for in, want := range cases { + if got := slugify(in); got != want { + t.Errorf("slugify(%q) = %q, attendu %q", in, got, want) + } + } +} diff --git a/internal/loki/web_server.go b/internal/loki/web_server.go index 534619c..db7cce8 100644 --- a/internal/loki/web_server.go +++ b/internal/loki/web_server.go @@ -254,6 +254,7 @@ func newWebMux() *http.ServeMux { api("/api/chat/send", handleChatSend) // envoie un message (lance la génération détachée) api("/api/chat/upload", handleChatUpload) // dépose un fichier dans le workspace agent (joint au message suivant) api("/api/chat/files", handleChatFiles) // liste un dossier de la discussion ouverte (panneau Fichiers) + api("/api/chat/files/zip", handleChatFilesZip) // toute la discussion en une archive .zip api("/api/chat/file/delete", handleChatFileDelete) // supprime un fichier ou un dossier de la discussion api("/api/chat/file", handleChatFile) // télécharge un fichier produit par l'agent (dossier de travail only) api("/api/chat/image", handleChatImage) // affiche une IMAGE du dossier de travail (captures d'écran)