mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Panneau Fichiers : toute la discussion en une archive .zip
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
53d23418e9
commit
a7587235c6
5 files changed
+447
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user