From 941a67b6eaee15210145dde6767a0c49bfd68264 Mon Sep 17 00:00:00 2001 From: swanadiva Date: Tue, 7 Jul 2026 13:35:01 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20Sprint=204=20=E2=80=94=20SFTP=20dual-pa?= =?UTF-8?q?ne=20with=20per-type=20icons,=20breadcrumb,=20drop-zone,=20crea?= =?UTF-8?q?te/delete,=20toast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 4 +- app/backend/internal/handler/sftp.go | 78 +++++++++++++++ app/backend/internal/model/file.go | 60 ++++++++++++ app/backend/internal/model/transfer.go | 22 +++++ app/backend/main.go | 13 +++ app/backend/views/sftp.html | 128 +++++++++++++++++++++++++ app/backend/views/sftp_pane.html | 51 ++++++++++ docs/PROGRESS.md | 18 ++-- 8 files changed, 364 insertions(+), 10 deletions(-) create mode 100644 app/backend/internal/handler/sftp.go create mode 100644 app/backend/internal/model/file.go create mode 100644 app/backend/internal/model/transfer.go create mode 100644 app/backend/views/sftp.html create mode 100644 app/backend/views/sftp_pane.html diff --git a/AGENTS.md b/AGENTS.md index 9bdf4ed..381b8cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ > **Purpose**: Memungkinkan AI agent berikutnya melanjutkan development tanpa mengulang atau merusak. > **Last Updated**: 2026-07-07 -> **Current Phase**: Sprint 1 + 2 + 3 selesai, Sprint 4 berikutnya +> **Current Phase**: Sprint 1 + 2 + 3 + 4 selesai, Sprint 5 berikutnya --- @@ -116,7 +116,7 @@ Alasan: ### ❌ Belum Dimulai / In Progress - **Sprint 2 (DONE)**: Snippets + Keychain - **Sprint 3 (DONE)**: Settings + Brief -- Sprint 4: SFTP Dual-Pane +- **Sprint 4 (DONE)**: SFTP Dual-Pane - Sprint 5: Terminal + xterm.js - Sprint 6: Storage Integration diff --git a/app/backend/internal/handler/sftp.go b/app/backend/internal/handler/sftp.go new file mode 100644 index 0000000..019b007 --- /dev/null +++ b/app/backend/internal/handler/sftp.go @@ -0,0 +1,78 @@ +package handler + +import ( + "github.com/gofiber/fiber/v2" + "git.tukangketik.id/swanadiva/hostkeeper/v2/internal/model" +) + +var fileStore = model.NewFileStore() + +func SFTP(c *fiber.Ctx) error { + local, remote := fileStore.All() + return c.Render("sftp", fiber.Map{ + "View": "sftp", + "Title": "SFTP", + "LocalFiles": local, + "RemoteFiles": remote, + "LocalBreadcrumb": fileStore.LocalBreadcrumb, + "RemoteBreadcrumb": fileStore.RemoteBreadcrumb, + "NavItems": navItems, + }) +} + +func SFTPPane(c *fiber.Ctx) error { + pane := c.Query("pane", "local") + dir := c.Query("dir", "/") + local, remote := fileStore.All() + files := local + breadcrumb := fileStore.LocalBreadcrumb + if pane == "remote" { + files = remote + breadcrumb = fileStore.RemoteBreadcrumb + } + return c.Render("sftp_pane", fiber.Map{ + "Pane": pane, + "Files": files, + "Breadcrumb": breadcrumb, + "CurrentDir": dir, + }) +} + +func CreateFolder(c *fiber.Ctx) error { + pane := c.FormValue("pane", "local") + name := c.FormValue("name") + if name != "" { + item := model.FileItem{ + Name: name, Size: "—", Modified: "Just now", + Type: "folder", Permissions: "drwxr-xr-x", + } + if pane == "remote" { + fileStore.Remote = append([]model.FileItem{item}, fileStore.Remote...) + } else { + fileStore.Local = append([]model.FileItem{item}, fileStore.Local...) + } + } + files := fileStore.Local + if pane == "remote" { files = fileStore.Remote } + return c.Render("sftp_pane", fiber.Map{ + "Pane": pane, "Files": files, + "Breadcrumb": fileStore.LocalBreadcrumb, + }) +} + +func DeleteFile(c *fiber.Ctx) error { + pane := c.Query("pane", "local") + name := c.Query("name") + files := &fileStore.Local + if pane == "remote" { files = &fileStore.Remote } + for i, f := range *files { + if f.Name == name { + *files = append((*files)[:i], (*files)[i+1:]...) + break + } + } + return c.Render("sftp_pane", fiber.Map{ + "Pane": pane, "Files": *files, + "Breadcrumb": fileStore.LocalBreadcrumb, + }) +} diff --git a/app/backend/internal/model/file.go b/app/backend/internal/model/file.go new file mode 100644 index 0000000..d39e066 --- /dev/null +++ b/app/backend/internal/model/file.go @@ -0,0 +1,60 @@ +package model + +type FileItem struct { + Name string `json:"name"` + Size string `json:"size"` + Modified string `json:"modified"` + Type string `json:"type"` // folder | file | image | code | lock + Extension string `json:"extension"` + Permissions string `json:"permissions"` + Path string `json:"path"` +} + +type FileStore struct { + Local []FileItem + Remote []FileItem + LocalBreadcrumb []Breadcrumb + RemoteBreadcrumb []Breadcrumb +} + +type Breadcrumb struct { + Name string + Path string +} + +func NewFileStore() *FileStore { + return &FileStore{ + Local: defaultLocalFiles(), + Remote: defaultRemoteFiles(), + LocalBreadcrumb: []Breadcrumb{{Name: "~", Path: "/home/user"}, {Name: "projects", Path: "/home/user/projects"}}, + RemoteBreadcrumb: []Breadcrumb{{Name: "/", Path: "/"}, {Name: "var", Path: "/var"}, {Name: "www", Path: "/var/www"}}, + } +} + +func (s *FileStore) All() (local, remote []FileItem) { return s.Local, s.Remote } + +func defaultLocalFiles() []FileItem { + return []FileItem{ + {Name: "Downloads", Size: "—", Modified: "2 hours ago", Type: "folder", Permissions: "drwxr-xr-x", Path: "/home/user/Downloads"}, + {Name: "Documents", Size: "—", Modified: "Yesterday", Type: "folder", Permissions: "drwxr-xr-x", Path: "/home/user/Documents"}, + {Name: "projects", Size: "—", Modified: "Just now", Type: "folder", Permissions: "drwxrwxr-x", Path: "/home/user/projects"}, + {Name: "config.yml", Size: "1.4 KB", Modified: "Yesterday", Type: "code", Extension: "yml", Permissions: "-rw-r--r--", Path: "/home/user/projects/config.yml"}, + {Name: "README.md", Size: "3.2 KB", Modified: "2 days ago", Type: "code", Extension: "md", Permissions: "-rw-r--r--", Path: "/home/user/projects/README.md"}, + {Name: "screenshot.png", Size: "240 KB", Modified: "3 days ago", Type: "image", Extension: "png", Permissions: "-rw-r--r--", Path: "/home/user/Downloads/screenshot.png"}, + {Name: "archive.zip", Size: "42 MB", Modified: "1 week ago", Type: "file", Extension: "zip", Permissions: "-rw-r--r--", Path: "/home/user/Downloads/archive.zip"}, + {Name: "vault.enc", Size: "8 KB", Modified: "2 weeks ago", Type: "lock", Extension: "enc", Permissions: "-rw-------", Path: "/home/user/vault.enc"}, + } +} + +func defaultRemoteFiles() []FileItem { + return []FileItem{ + {Name: "html", Size: "—", Modified: "2 hours ago", Type: "folder", Permissions: "drwxr-xr-x", Path: "/var/www/html"}, + {Name: "assets", Size: "—", Modified: "Yesterday", Type: "folder", Permissions: "drwxr-xr-x", Path: "/var/www/assets"}, + {Name: "index.html", Size: "4.1 KB", Modified: "Just now", Type: "code", Extension: "html", Permissions: "-rw-r--r--", Path: "/var/www/index.html"}, + {Name: "app.py", Size: "12.8 KB", Modified: "2 hours ago", Type: "code", Extension: "py", Permissions: "-rwxr-xr-x", Path: "/var/www/app.py"}, + {Name: "Dockerfile", Size: "512 B", Modified: "Yesterday", Type: "code", Extension: "Dockerfile", Permissions: "-rw-r--r--", Path: "/var/www/Dockerfile"}, + {Name: "compose.yml", Size: "2.1 KB", Modified: "Yesterday", Type: "code", Extension: "yml", Permissions: "-rw-r--r--", Path: "/var/www/compose.yml"}, + {Name: ".env", Size: "156 B", Modified: "3 days ago", Type: "lock", Extension: "env", Permissions: "-rw-------", Path: "/var/www/.env"}, + {Name: "logo.svg", Size: "6.7 KB", Modified: "1 week ago", Type: "image", Extension: "svg", Permissions: "-rw-r--r--", Path: "/var/www/assets/logo.svg"}, + } +} diff --git a/app/backend/internal/model/transfer.go b/app/backend/internal/model/transfer.go new file mode 100644 index 0000000..308ca3d --- /dev/null +++ b/app/backend/internal/model/transfer.go @@ -0,0 +1,22 @@ +package model + +type Transfer struct { + ID string `json:"id"` + FileName string `json:"fileName"` + Source string `json:"source"` + Destination string `json:"destination"` + Progress int `json:"progress"` + Speed string `json:"speed"` + Type string `json:"type"` // upload | download + Status string `json:"status"` // queued | transferring | completed | error +} + +type TransferStore struct { + Transfers []Transfer +} + +func NewTransferStore() *TransferStore { + return &TransferStore{} +} + +func (s *TransferStore) All() []Transfer { return s.Transfers } diff --git a/app/backend/main.go b/app/backend/main.go index 00c40d9..a5061ee 100644 --- a/app/backend/main.go +++ b/app/backend/main.go @@ -18,6 +18,15 @@ func main() { engine.AddFunc("svg", func(name string) string { return "" }) + engine.AddFunc("dict", func(values ...interface{}) map[string]interface{} { + m := make(map[string]interface{}) + for i := 0; i < len(values)-1; i += 2 { + if key, ok := values[i].(string); ok { + m[key] = values[i+1] + } + } + return m + }) app := fiber.New(fiber.Config{ Views: engine, @@ -43,6 +52,10 @@ func main() { app.Get("/settings", handler.Settings) app.Post("/settings", handler.UpdateConfig) app.Get("/brief", handler.Brief) + app.Get("/sftp", handler.SFTP) + app.Get("/sftp/pane", handler.SFTPPane) + app.Post("/sftp/mkdir", handler.CreateFolder) + app.Delete("/sftp/rm", handler.DeleteFile) app.Get("/api/health", handler.Health) log.Fatal(app.Listen(":1947")) diff --git a/app/backend/views/sftp.html b/app/backend/views/sftp.html new file mode 100644 index 0000000..9161ff9 --- /dev/null +++ b/app/backend/views/sftp.html @@ -0,0 +1,128 @@ + + + + + + SFTP · Hostkeeper V2 + + + + + + + + +
+ {{template "nav" .}} +
+
+

{{.Title}}

+
+
+
+ +
+ +
+
+ + Local Workstation +
+ + +
+
+ {{range .LocalBreadcrumb}} + {{.Name}} + / + {{end}} +
+
+ {{template "sftp_pane" dict "Pane" "local" "Files" .LocalFiles "Breadcrumb" .LocalBreadcrumb}} +
+
+ + +
+
+ + Remote Cluster Node +
+ + +
+
+ {{range .RemoteBreadcrumb}} + {{.Name}} + / + {{end}} +
+
+ {{template "sftp_pane" dict "Pane" "remote" "Files" .RemoteFiles "Breadcrumb" .RemoteBreadcrumb}} +
+
+
+ + +
+ +

Drop files here to upload

+

or click to browse

+
+ + +
+
+ + +
+
+
+
+
+

New Folder

+ +
+
+ +
+ + +
+
+ + +
+
+
+
+
+
+
+
+
+ + diff --git a/app/backend/views/sftp_pane.html b/app/backend/views/sftp_pane.html new file mode 100644 index 0000000..1e0f0d0 --- /dev/null +++ b/app/backend/views/sftp_pane.html @@ -0,0 +1,51 @@ +{{define "sftp_pane"}} +
+ {{range .Files}} +
+
+ + {{if eq .Type "folder"}} + + {{else if eq .Type "code"}} + + {{else if eq .Type "image"}} + + {{else if eq .Type "lock"}} + + {{else}} + + {{end}} + + {{.Name}} +
+ {{.Size}} + {{.Modified}} + +
+ {{if ne .Type "folder"}} + + + {{end}} + +
+
+ {{end}} + + {{if eq (len .Files) 0}} +
+

This directory is empty

+
+ {{end}} +
+{{end}} diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 17435bd..e4e80f8 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -38,7 +38,7 @@ Lihat [ARCHITECTURE_HTMX.md](ARCHITECTURE_HTMX.md) untuk detail. | Sprint 1 — Dashboard + Host List | DONE | | Sprint 2 — Snippets + Keychain | DONE | | Sprint 3 — Settings + Brief | DONE | -| Sprint 4 — SFTP Dual-Pane | NOT STARTED | +| Sprint 4 — SFTP Dual-Pane | DONE | | Sprint 5 — Terminal + xterm.js | NOT STARTED | | Sprint 6 — Storage Integration | NOT STARTED | @@ -99,12 +99,13 @@ Lihat [ARCHITECTURE_HTMX.md](ARCHITECTURE_HTMX.md) untuk detail. - **Verify**: Settings page renders with tabs, toggles work via Alpine, brief page shows shortcuts ### Sprint 4 — SFTP Dual-Pane -- [ ] Model FileItem + Transfer -- [ ] SFTP handler -- [ ] SFTP template with dual-pane layout -- [ ] Breadcrumb navigation -- [ ] Folder create + delete -- [ ] Toast notification +- [x] Model FileItem + Transfer (`internal/model/file.go`, `internal/model/transfer.go`) +- [x] SFTP handler (`internal/handler/sftp.go`) — page, pane partial, create folder, delete file +- [x] SFTP template (`views/sftp.html`) — dual-pane (local/remote), breadcrumb, search, drop-zone, toast, folder modal +- [x] SFTP pane partial (`views/sftp_pane.html`) — file table with icons by type, hover actions +- [x] `dict` helper fungsitambah di template engine (`main.go`) +- [x] Folder create (Alpine modal + POST) + delete (hx-delete) +- **Verify**: Dual-pane renders, file list per type icons, CRUD works, drop-zone + toast present ### Sprint 5 — Terminal + xterm.js - [ ] Download xterm.js @@ -144,7 +145,8 @@ Lihat [ARCHITECTURE_HTMX.md](ARCHITECTURE_HTMX.md) untuk detail. | 2026-07-07 | Sprint 1: Host model+store, dashboard CRUD, HTMX partials (grid/list, search, filter, modal, delete), transfers panel | `d771925`, `86620b0` | | 2026-07-07 | Sprint 2 dimulai: Snippet + Keychain models created | — | | 2026-07-07 | Sprint 2 selesai: all CRUD, standalone templates, clipboard.js, utils.js, fix template {{define}} conflict | `c4b0d7d` | -| 2026-07-07 | Sprint 3 selesai: settings page, tabs, toggles, connected devices, danger zone, brief page | `(pending)` | +| 2026-07-07 | Sprint 3 selesai: settings page, tabs, toggles, connected devices, danger zone, brief page | `c5ac07e` | +| 2026-07-07 | Sprint 4 selesai: SFTP dual-pane, breadcrumb, per-type icons, create/delete, toast, drop-zone | `(pending)` | ---