mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 18:18:37 +00:00
feat(storage): add lazy folder loading, SSE size endpoint, and enhanced file viewer
- Backend: depth-limited WalkDir (max 3 levels default) with on-demand subtree loading
- Backend: new GET /v1/storage/size SSE endpoint with 60min in-memory cache
- Backend: raw binary file serving (?raw=true) with MIME detection and download support
- Frontend: lazy tree expansion with loading spinners for deep folders
- Frontend: streaming size display with cache info tooltip
- Frontend: image viewer (blob URL), unsupported file UI, download button, colored size badges
- Frontend: file-type icons for 13 categories (md, json, yaml, images, video, etc.)
- Fix sidebar connection status text overflow on collapse
- Apply go fix modernization (interface{} → any) across http handlers
This commit is contained in:
1 parent
4ec1c5e71a
commit
28fab9507a
43 files changed
+825
-257
No files matched your search
@@ -20,8 +20,8 @@ import (
|
||||
type AgentsHandler struct {
|
||||
agents store.AgentStore
|
||||
token string
|
||||
msgBus *bus.MessageBus // for cache invalidation events (nil = no events)
|
||||
summoner *AgentSummoner // LLM-based agent setup (nil = disabled)
|
||||
msgBus *bus.MessageBus // for cache invalidation events (nil = no events)
|
||||
summoner *AgentSummoner // LLM-based agent setup (nil = disabled)
|
||||
isOwner func(string) bool // checks if user ID is a system owner (nil = no owners configured)
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ func (h *AgentsHandler) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"agents": agents})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"agents": agents})
|
||||
}
|
||||
|
||||
func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -38,7 +38,7 @@ func (h *AgentsHandler) handleListInstances(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"instances": instances})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"instances": instances})
|
||||
}
|
||||
|
||||
// handleGetInstanceFiles returns user context files for a specific instance.
|
||||
@@ -72,7 +72,7 @@ func (h *AgentsHandler) handleGetInstanceFiles(w http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"files": files})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"files": files})
|
||||
}
|
||||
|
||||
// handleSetInstanceFile updates a user context file for a specific instance.
|
||||
|
||||
@@ -36,7 +36,7 @@ func (h *AgentsHandler) handleListShares(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"shares": shares})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"shares": shares})
|
||||
}
|
||||
|
||||
func (h *AgentsHandler) handleShare(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -225,7 +225,7 @@ func extractDescription(raw json.RawMessage) string {
|
||||
if len(raw) == 0 {
|
||||
return ""
|
||||
}
|
||||
var cfg map[string]interface{}
|
||||
var cfg map[string]any
|
||||
if json.Unmarshal(raw, &cfg) != nil {
|
||||
return ""
|
||||
}
|
||||
@@ -233,7 +233,7 @@ func extractDescription(raw json.RawMessage) string {
|
||||
return desc
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
|
||||
func writeJSON(w http.ResponseWriter, status int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
|
||||
@@ -50,11 +50,11 @@ func extractUserID(r *http.Request) string {
|
||||
// Checks model field, headers, and falls back to "default".
|
||||
func extractAgentID(r *http.Request, model string) string {
|
||||
// From model field: "goclaw:<agentId>" or "agent:<agentId>"
|
||||
if strings.HasPrefix(model, "goclaw:") {
|
||||
return strings.TrimPrefix(model, "goclaw:")
|
||||
if after, ok := strings.CutPrefix(model, "goclaw:"); ok {
|
||||
return after
|
||||
}
|
||||
if strings.HasPrefix(model, "agent:") {
|
||||
return strings.TrimPrefix(model, "agent:")
|
||||
if after, ok := strings.CutPrefix(model, "agent:"); ok {
|
||||
return after
|
||||
}
|
||||
|
||||
// From headers
|
||||
@@ -76,7 +76,7 @@ func extractLocale(r *http.Request) string {
|
||||
return i18n.DefaultLocale
|
||||
}
|
||||
// Simple parser: take the first language tag before comma or semicolon
|
||||
for _, part := range strings.Split(accept, ",") {
|
||||
for part := range strings.SplitSeq(accept, ",") {
|
||||
tag := strings.TrimSpace(strings.SplitN(part, ";", 2)[0])
|
||||
locale := i18n.Normalize(tag)
|
||||
if locale != i18n.DefaultLocale || strings.HasPrefix(tag, "en") {
|
||||
|
||||
@@ -62,7 +62,7 @@ func (h *BuiltinToolsHandler) handleList(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "tools")})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"tools": result})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"tools": result})
|
||||
}
|
||||
|
||||
func (h *BuiltinToolsHandler) handleGet(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -90,7 +90,7 @@ func (h *BuiltinToolsHandler) handleUpdate(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
var updates map[string]interface{}
|
||||
var updates map[string]any
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&updates); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
|
||||
return
|
||||
|
||||
@@ -111,12 +111,12 @@ func (h *ChannelInstancesHandler) handleList(w http.ResponseWriter, r *http.Requ
|
||||
|
||||
total, _ := h.store.CountInstances(r.Context(), opts)
|
||||
|
||||
result := make([]map[string]interface{}, 0, len(instances))
|
||||
result := make([]map[string]any, 0, len(instances))
|
||||
for _, inst := range instances {
|
||||
result = append(result, maskInstanceHTTP(inst))
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"instances": result,
|
||||
"total": total,
|
||||
"limit": opts.Limit,
|
||||
@@ -210,7 +210,7 @@ func (h *ChannelInstancesHandler) handleUpdate(w http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
|
||||
var updates map[string]interface{}
|
||||
var updates map[string]any
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&updates); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
|
||||
return
|
||||
@@ -258,8 +258,8 @@ func (h *ChannelInstancesHandler) handleDelete(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
|
||||
// maskInstanceHTTP returns a map with credentials masked for HTTP responses.
|
||||
func maskInstanceHTTP(inst store.ChannelInstanceData) map[string]interface{} {
|
||||
result := map[string]interface{}{
|
||||
func maskInstanceHTTP(inst store.ChannelInstanceData) map[string]any {
|
||||
result := map[string]any{
|
||||
"id": inst.ID,
|
||||
"name": inst.Name,
|
||||
"display_name": inst.DisplayName,
|
||||
@@ -275,9 +275,9 @@ func maskInstanceHTTP(inst store.ChannelInstanceData) map[string]interface{} {
|
||||
}
|
||||
|
||||
if len(inst.Credentials) > 0 {
|
||||
var raw map[string]interface{}
|
||||
var raw map[string]any
|
||||
if json.Unmarshal(inst.Credentials, &raw) == nil {
|
||||
masked := make(map[string]interface{}, len(raw))
|
||||
masked := make(map[string]any, len(raw))
|
||||
for k := range raw {
|
||||
masked[k] = "***"
|
||||
}
|
||||
@@ -325,7 +325,7 @@ func (h *ChannelInstancesHandler) handleWriterGroups(w http.ResponseWriter, r *h
|
||||
if groups == nil {
|
||||
groups = []store.GroupWriterGroupInfo{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"groups": groups})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"groups": groups})
|
||||
}
|
||||
|
||||
func (h *ChannelInstancesHandler) handleListWriters(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -348,7 +348,7 @@ func (h *ChannelInstancesHandler) handleListWriters(w http.ResponseWriter, r *ht
|
||||
if writers == nil {
|
||||
writers = []store.GroupFileWriterData{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"writers": writers})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"writers": writers})
|
||||
}
|
||||
|
||||
func (h *ChannelInstancesHandler) handleAddWriter(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -443,7 +443,7 @@ func (h *ChannelInstancesHandler) handleListContacts(w http.ResponseWriter, r *h
|
||||
slog.Warn("contacts.count", "error", countErr)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"contacts": contacts,
|
||||
"total": total,
|
||||
"limit": opts.Limit,
|
||||
@@ -454,7 +454,7 @@ func (h *ChannelInstancesHandler) handleListContacts(w http.ResponseWriter, r *h
|
||||
func (h *ChannelInstancesHandler) handleResolveContacts(w http.ResponseWriter, r *http.Request) {
|
||||
idsParam := r.URL.Query().Get("ids")
|
||||
if idsParam == "" {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"contacts": map[string]any{}})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"contacts": map[string]any{}})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -471,7 +471,7 @@ func (h *ChannelInstancesHandler) handleResolveContacts(w http.ResponseWriter, r
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"contacts": result})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"contacts": result})
|
||||
}
|
||||
|
||||
// isValidChannelType checks if the channel type is supported.
|
||||
|
||||
@@ -19,9 +19,9 @@ import (
|
||||
type ChatCompletionsHandler struct {
|
||||
agents *agent.Router
|
||||
sessions store.SessionStore
|
||||
token string // expected bearer token (empty = no auth)
|
||||
token string // expected bearer token (empty = no auth)
|
||||
isManaged bool
|
||||
rateLimiter func(string) bool // rate limit check: key → allowed (nil = no limit)
|
||||
rateLimiter func(string) bool // rate limit check: key → allowed (nil = no limit)
|
||||
}
|
||||
|
||||
// NewChatCompletionsHandler creates a handler for the chat completions endpoint.
|
||||
@@ -40,10 +40,10 @@ func (h *ChatCompletionsHandler) SetRateLimiter(fn func(string) bool) {
|
||||
}
|
||||
|
||||
type chatCompletionsRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []chatMessage `json:"messages"`
|
||||
Stream bool `json:"stream"`
|
||||
User string `json:"user,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Messages []chatMessage `json:"messages"`
|
||||
Stream bool `json:"stream"`
|
||||
User string `json:"user,omitempty"`
|
||||
}
|
||||
|
||||
type chatMessage struct {
|
||||
@@ -53,12 +53,12 @@ type chatMessage struct {
|
||||
}
|
||||
|
||||
type chatCompletionsResponse struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []chatChoice `json:"choices"`
|
||||
Usage *chatUsage `json:"usage,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []chatChoice `json:"choices"`
|
||||
Usage *chatUsage `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
type chatChoice struct {
|
||||
@@ -247,12 +247,12 @@ func (h *ChatCompletionsHandler) handleStream(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
|
||||
func writeSSEChunk(w http.ResponseWriter, flusher http.Flusher, id, model string, delta *chatMessage, finishReason string) {
|
||||
chunk := map[string]interface{}{
|
||||
chunk := map[string]any{
|
||||
"id": id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": time.Now().Unix(),
|
||||
"model": model,
|
||||
"choices": []map[string]interface{}{{
|
||||
"choices": []map[string]any{{
|
||||
"index": 0,
|
||||
"delta": delta,
|
||||
"finish_reason": nilIfEmpty(finishReason),
|
||||
@@ -264,7 +264,7 @@ func writeSSEChunk(w http.ResponseWriter, flusher http.Flusher, id, model string
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
func nilIfEmpty(s string) interface{} {
|
||||
func nilIfEmpty(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ func (h *CustomToolsHandler) handleList(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
total, _ := h.store.CountTools(r.Context(), opts)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"tools": result,
|
||||
"total": total,
|
||||
"limit": opts.Limit,
|
||||
@@ -182,7 +182,7 @@ func (h *CustomToolsHandler) handleUpdate(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
var updates map[string]interface{}
|
||||
var updates map[string]any
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&updates); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
|
||||
return
|
||||
|
||||
@@ -85,7 +85,7 @@ func (h *DelegationsHandler) handleList(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"records": records,
|
||||
"total": total,
|
||||
})
|
||||
|
||||
@@ -73,7 +73,7 @@ func (h *KnowledgeGraphHandler) handleGetEntity(w http.ResponseWriter, r *http.R
|
||||
relations = []store.Relation{}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"entity": entity,
|
||||
"relations": relations,
|
||||
})
|
||||
@@ -208,7 +208,7 @@ func (h *KnowledgeGraphHandler) handleExtract(w http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"entities": len(result.Entities),
|
||||
"relations": len(result.Relations),
|
||||
})
|
||||
@@ -257,7 +257,7 @@ func (h *KnowledgeGraphHandler) handleGraph(w http.ResponseWriter, r *http.Reque
|
||||
relations = []store.Relation{}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"entities": entities,
|
||||
"relations": relations,
|
||||
})
|
||||
|
||||
@@ -115,7 +115,7 @@ func (h *MCPHandler) handleListServers(w http.ResponseWriter, r *http.Request) {
|
||||
result[i] = mcpServerWithCounts{MCPServerData: srv, AgentCount: counts[srv.ID]}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"servers": result})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"servers": result})
|
||||
}
|
||||
|
||||
func (h *MCPHandler) handleCreateServer(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -176,7 +176,7 @@ func (h *MCPHandler) handleUpdateServer(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
var updates map[string]interface{}
|
||||
var updates map[string]any
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&updates); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
|
||||
return
|
||||
|
||||
@@ -95,7 +95,7 @@ func (h *MCPHandler) handleListAgentGrants(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"grants": grants})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"grants": grants})
|
||||
}
|
||||
|
||||
func (h *MCPHandler) handleListServerGrants(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -113,7 +113,7 @@ func (h *MCPHandler) handleListServerGrants(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"grants": grants})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"grants": grants})
|
||||
}
|
||||
|
||||
func (h *MCPHandler) handleGrantUser(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -49,7 +49,7 @@ func (h *MCPHandler) handleListPendingRequests(w http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"requests": requests})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"requests": requests})
|
||||
}
|
||||
|
||||
func (h *MCPHandler) handleReviewRequest(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// handleTestConnection tests an MCP server connection without saving it.
|
||||
@@ -34,14 +34,14 @@ func (h *MCPHandler) handleTestConnection(w http.ResponseWriter, r *http.Request
|
||||
|
||||
tools, err := mcpbridge.DiscoverTools(r.Context(), req.Transport, req.Command, req.Args, req.Env, req.URL, req.Headers)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"success": true,
|
||||
"tool_count": len(tools),
|
||||
})
|
||||
@@ -92,5 +92,5 @@ func (h *MCPHandler) handleListServerTools(w http.ResponseWriter, r *http.Reques
|
||||
if tools == nil {
|
||||
tools = []mcpbridge.ToolInfo{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"tools": tools})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"tools": tools})
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func (h *MediaUploadHandler) handleUpload(w http.ResponseWriter, r *http.Request
|
||||
|
||||
mimeType := media.DetectMIMEType(origName)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"path": tmpPath,
|
||||
"mime_type": mimeType,
|
||||
"filename": origName,
|
||||
|
||||
@@ -189,7 +189,7 @@ func (h *MemoryHandler) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
if results == nil {
|
||||
results = []store.MemorySearchResult{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"results": results,
|
||||
"count": len(results),
|
||||
})
|
||||
|
||||
@@ -133,7 +133,7 @@ func TestOAuthHandlerStatusNoToken(t *testing.T) {
|
||||
t.Fatalf("status code = %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
var result map[string]any
|
||||
json.NewDecoder(w.Body).Decode(&result)
|
||||
|
||||
if result["authenticated"] != false {
|
||||
@@ -228,7 +228,7 @@ func TestOAuthHandlerStartReturnsAuthURL(t *testing.T) {
|
||||
t.Fatalf("status code = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
var result map[string]any
|
||||
json.NewDecoder(w.Body).Decode(&result)
|
||||
|
||||
_, hasURL := result["auth_url"]
|
||||
|
||||
@@ -19,10 +19,10 @@ type PendingMessagesHandler struct {
|
||||
agentStore store.AgentStore
|
||||
token string
|
||||
providerReg *providers.Registry
|
||||
keepRecent int // global keepRecent from config (0 = use default 15)
|
||||
maxTokens int // max output tokens for LLM summarization (0 = use default)
|
||||
cfgProvider string // config-level provider override (empty = resolve from agent)
|
||||
cfgModel string // config-level model override (empty = resolve from agent)
|
||||
keepRecent int // global keepRecent from config (0 = use default 15)
|
||||
maxTokens int // max output tokens for LLM summarization (0 = use default)
|
||||
cfgProvider string // config-level provider override (empty = resolve from agent)
|
||||
cfgModel string // config-level model override (empty = resolve from agent)
|
||||
}
|
||||
|
||||
func NewPendingMessagesHandler(s store.PendingMessageStore, agentStore store.AgentStore, token string, providerReg *providers.Registry) *PendingMessagesHandler {
|
||||
@@ -81,7 +81,7 @@ func (h *PendingMessagesHandler) handleListGroups(w http.ResponseWriter, r *http
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"groups": groups})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"groups": groups})
|
||||
}
|
||||
|
||||
// GET /v1/pending-messages/messages?channel=X&key=Y — list messages for a group
|
||||
@@ -99,7 +99,7 @@ func (h *PendingMessagesHandler) handleListMessages(w http.ResponseWriter, r *ht
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"messages": msgs})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"messages": msgs})
|
||||
}
|
||||
|
||||
// DELETE /v1/pending-messages?channel=X&key=Y — clear a group
|
||||
@@ -169,7 +169,7 @@ func (h *PendingMessagesHandler) handleCompact(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
}()
|
||||
|
||||
writeJSON(w, http.StatusAccepted, map[string]interface{}{"status": "accepted", "method": "summarizing"})
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"status": "accepted", "method": "summarizing"})
|
||||
}
|
||||
|
||||
// resolveProviderAndModel resolves the LLM provider+model for pending message compaction.
|
||||
|
||||
@@ -41,13 +41,13 @@ func (h *ProvidersHandler) handleListProviderModels(w http.ResponseWriter, r *ht
|
||||
|
||||
// Claude CLI doesn't need an API key — return hardcoded models
|
||||
if p.ProviderType == store.ProviderClaudeCLI {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"models": claudeCLIModels()})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"models": claudeCLIModels()})
|
||||
return
|
||||
}
|
||||
|
||||
// ACP agents don't need an API key — return hardcoded models
|
||||
if p.ProviderType == store.ProviderACP {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"models": acpModels()})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"models": acpModels()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -86,11 +86,11 @@ func (h *ProvidersHandler) handleListProviderModels(w http.ResponseWriter, r *ht
|
||||
if err != nil {
|
||||
slog.Warn("providers.models", "provider", p.Name, "error", err)
|
||||
// Return empty list instead of error — provider may not support /models
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"models": []ModelInfo{}})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"models": []ModelInfo{}})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"models": models})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"models": models})
|
||||
}
|
||||
|
||||
// fetchAnthropicModels calls the Anthropic models API.
|
||||
@@ -289,4 +289,3 @@ func fetchOpenAIModels(ctx context.Context, apiBase, apiKey string) ([]ModelInfo
|
||||
}
|
||||
return models, nil
|
||||
}
|
||||
|
||||
@@ -55,13 +55,13 @@ func (h *ProvidersHandler) handleVerifyProvider(w http.ResponseWriter, r *http.R
|
||||
}
|
||||
// Validate binary against known allowlist (same check as registerACPFromDB)
|
||||
if binary != "claude" && binary != "codex" && binary != "gemini" && !filepath.IsAbs(binary) {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"valid": false, "error": "invalid binary path"})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"valid": false, "error": "invalid binary path"})
|
||||
return
|
||||
}
|
||||
if _, err := exec.LookPath(binary); err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"valid": false, "error": "binary not found: " + binary})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"valid": false, "error": "binary not found: " + binary})
|
||||
} else {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"valid": true})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"valid": true})
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -70,28 +70,28 @@ func (h *ProvidersHandler) handleVerifyProvider(w http.ResponseWriter, r *http.R
|
||||
if p.ProviderType == "claude_cli" {
|
||||
validModels := map[string]bool{"sonnet": true, "opus": true, "haiku": true}
|
||||
if validModels[req.Model] {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"valid": true})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"valid": true})
|
||||
} else {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"valid": false, "error": "Invalid model. Use: sonnet, opus, or haiku"})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"valid": false, "error": "Invalid model. Use: sonnet, opus, or haiku"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if h.providerReg == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"valid": false, "error": "no provider registry available"})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"valid": false, "error": "no provider registry available"})
|
||||
return
|
||||
}
|
||||
|
||||
provider, err := h.providerReg.Get(p.Name)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"valid": false, "error": "provider not registered: " + p.Name})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"valid": false, "error": "provider not registered: " + p.Name})
|
||||
return
|
||||
}
|
||||
|
||||
// Non-chat models (image/video generation) can't be verified via Chat API.
|
||||
// Accept them if the provider is reachable (already validated above).
|
||||
if isNonChatModel(req.Model) {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"valid": true, "note": "generation model accepted (not verifiable via chat)"})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"valid": true, "note": "generation model accepted (not verifiable via chat)"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -103,16 +103,16 @@ func (h *ProvidersHandler) handleVerifyProvider(w http.ResponseWriter, r *http.R
|
||||
{Role: "user", Content: "hi"},
|
||||
},
|
||||
Model: req.Model,
|
||||
Options: map[string]interface{}{
|
||||
Options: map[string]any{
|
||||
"max_tokens": 1,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"valid": false, "error": friendlyVerifyError(err)})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"valid": false, "error": friendlyVerifyError(err)})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"valid": true})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"valid": true})
|
||||
}
|
||||
|
||||
// handleClaudeCLIAuthStatus checks whether the Claude CLI is authenticated on the server.
|
||||
@@ -137,14 +137,14 @@ func (h *ProvidersHandler) handleClaudeCLIAuthStatus(w http.ResponseWriter, r *h
|
||||
|
||||
status, err := providers.CheckClaudeAuthStatus(ctx, cliPath)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"logged_in": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"logged_in": status.LoggedIn,
|
||||
"email": status.Email,
|
||||
"subscription_type": status.SubscriptionType,
|
||||
@@ -183,8 +183,8 @@ func friendlyVerifyError(err error) string {
|
||||
rest = strings.TrimLeft(rest[start+1:], " ")
|
||||
if len(rest) > 0 && rest[0] == '"' {
|
||||
rest = rest[1:]
|
||||
if end := strings.Index(rest, `"`); end >= 0 {
|
||||
extracted := rest[:end]
|
||||
if before, _, ok := strings.Cut(rest, `"`); ok {
|
||||
extracted := before
|
||||
if extracted != "" {
|
||||
return extracted
|
||||
}
|
||||
|
||||
+10
-10
@@ -18,14 +18,14 @@ import (
|
||||
|
||||
// ProvidersHandler handles LLM provider CRUD endpoints.
|
||||
type ProvidersHandler struct {
|
||||
store store.ProviderStore
|
||||
secretStore store.ConfigSecretsStore
|
||||
token string
|
||||
providerReg *providers.Registry
|
||||
gatewayAddr string // for injecting MCP bridge into Claude CLI providers
|
||||
mcpLookup providers.MCPServerLookup // optional: resolves per-agent MCP servers
|
||||
cliMu sync.Mutex // serializes Claude CLI provider create to prevent duplicates
|
||||
msgBus *bus.MessageBus
|
||||
store store.ProviderStore
|
||||
secretStore store.ConfigSecretsStore
|
||||
token string
|
||||
providerReg *providers.Registry
|
||||
gatewayAddr string // for injecting MCP bridge into Claude CLI providers
|
||||
mcpLookup providers.MCPServerLookup // optional: resolves per-agent MCP servers
|
||||
cliMu sync.Mutex // serializes Claude CLI provider create to prevent duplicates
|
||||
msgBus *bus.MessageBus
|
||||
}
|
||||
|
||||
// NewProvidersHandler creates a handler for provider management endpoints.
|
||||
@@ -165,7 +165,7 @@ func (h *ProvidersHandler) handleListProviders(w http.ResponseWriter, r *http.Re
|
||||
maskAPIKey(&providers[i])
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"providers": providers})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"providers": providers})
|
||||
}
|
||||
|
||||
func (h *ProvidersHandler) handleCreateProvider(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -247,7 +247,7 @@ func (h *ProvidersHandler) handleUpdateProvider(w http.ResponseWriter, r *http.R
|
||||
return
|
||||
}
|
||||
|
||||
var updates map[string]interface{}
|
||||
var updates map[string]any
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&updates); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
|
||||
return
|
||||
|
||||
+11
-11
@@ -124,10 +124,10 @@ func (h *ResponsesHandler) handleNonStream(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
resp := map[string]interface{}{
|
||||
resp := map[string]any{
|
||||
"id": responseID,
|
||||
"status": "completed",
|
||||
"output": []map[string]interface{}{{
|
||||
"output": []map[string]any{{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": []map[string]string{{"type": "text", "text": result.Content}},
|
||||
@@ -159,9 +159,9 @@ func (h *ResponsesHandler) handleStream(w http.ResponseWriter, r *http.Request,
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
// response.started
|
||||
writeResponseEvent(w, flusher, map[string]interface{}{
|
||||
writeResponseEvent(w, flusher, map[string]any{
|
||||
"type": "response.started",
|
||||
"response": map[string]interface{}{
|
||||
"response": map[string]any{
|
||||
"id": responseID,
|
||||
"status": "in_progress",
|
||||
"created_at": time.Now().Unix(),
|
||||
@@ -180,9 +180,9 @@ func (h *ResponsesHandler) handleStream(w http.ResponseWriter, r *http.Request,
|
||||
|
||||
if err != nil {
|
||||
// response.done with error
|
||||
writeResponseEvent(w, flusher, map[string]interface{}{
|
||||
writeResponseEvent(w, flusher, map[string]any{
|
||||
"type": "response.done",
|
||||
"response": map[string]interface{}{
|
||||
"response": map[string]any{
|
||||
"id": responseID,
|
||||
"status": "failed",
|
||||
"error": err.Error(),
|
||||
@@ -192,16 +192,16 @@ func (h *ResponsesHandler) handleStream(w http.ResponseWriter, r *http.Request,
|
||||
}
|
||||
|
||||
// response.delta
|
||||
writeResponseEvent(w, flusher, map[string]interface{}{
|
||||
writeResponseEvent(w, flusher, map[string]any{
|
||||
"type": "response.delta",
|
||||
"delta": map[string]interface{}{
|
||||
"delta": map[string]any{
|
||||
"type": "content",
|
||||
"content": result.Content,
|
||||
},
|
||||
})
|
||||
|
||||
// response.done
|
||||
doneResp := map[string]interface{}{
|
||||
doneResp := map[string]any{
|
||||
"id": responseID,
|
||||
"status": "completed",
|
||||
}
|
||||
@@ -213,13 +213,13 @@ func (h *ResponsesHandler) handleStream(w http.ResponseWriter, r *http.Request,
|
||||
}
|
||||
}
|
||||
|
||||
writeResponseEvent(w, flusher, map[string]interface{}{
|
||||
writeResponseEvent(w, flusher, map[string]any{
|
||||
"type": "response.done",
|
||||
"response": doneResp,
|
||||
})
|
||||
}
|
||||
|
||||
func writeResponseEvent(w http.ResponseWriter, flusher http.Flusher, data interface{}) {
|
||||
func writeResponseEvent(w http.ResponseWriter, flusher http.Flusher, data any) {
|
||||
jsonData, _ := json.Marshal(data)
|
||||
fmt.Fprintf(w, "data: %s\n\n", jsonData)
|
||||
flusher.Flush()
|
||||
|
||||
@@ -83,7 +83,7 @@ func (h *SkillsHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
|
||||
func (h *SkillsHandler) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
skills := h.skills.ListSkills()
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"skills": skills})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"skills": skills})
|
||||
}
|
||||
|
||||
func (h *SkillsHandler) handleGet(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -106,7 +106,7 @@ func (h *SkillsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var updates map[string]interface{}
|
||||
var updates map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
|
||||
return
|
||||
@@ -168,7 +168,7 @@ func (h *SkillsHandler) handleInstallDeps(w http.ResponseWriter, r *http.Request
|
||||
if h.msgBus != nil {
|
||||
h.msgBus.Broadcast(bus.Event{
|
||||
Name: protocol.EventSkillDepsInstalling,
|
||||
Payload: map[string]interface{}{"count": len(missing)},
|
||||
Payload: map[string]any{"count": len(missing)},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -208,7 +208,7 @@ func (h *SkillsHandler) handleInstallDeps(w http.ResponseWriter, r *http.Request
|
||||
if h.msgBus != nil {
|
||||
h.msgBus.Broadcast(bus.Event{
|
||||
Name: protocol.EventSkillDepsChecked,
|
||||
Payload: map[string]interface{}{
|
||||
Payload: map[string]any{
|
||||
"slug": sk.Slug,
|
||||
"status": status,
|
||||
"missing": miss,
|
||||
@@ -241,14 +241,14 @@ func (h *SkillsHandler) handleInstallDep(w http.ResponseWriter, r *http.Request)
|
||||
if h.msgBus != nil {
|
||||
h.msgBus.Broadcast(bus.Event{
|
||||
Name: protocol.EventSkillDepItemInstalling,
|
||||
Payload: map[string]interface{}{"dep": body.Dep},
|
||||
Payload: map[string]any{"dep": body.Dep},
|
||||
})
|
||||
}
|
||||
|
||||
ok, errMsg := skills.InstallSingleDep(r.Context(), body.Dep)
|
||||
|
||||
if h.msgBus != nil {
|
||||
payload := map[string]interface{}{"dep": body.Dep, "ok": ok}
|
||||
payload := map[string]any{"dep": body.Dep, "ok": ok}
|
||||
if errMsg != "" {
|
||||
payload["error"] = errMsg
|
||||
}
|
||||
@@ -262,7 +262,7 @@ func (h *SkillsHandler) handleInstallDep(w http.ResponseWriter, r *http.Request)
|
||||
h.rescanAndUpdate()
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": ok, "error": errMsg})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": ok, "error": errMsg})
|
||||
}
|
||||
|
||||
type depResult struct {
|
||||
@@ -315,7 +315,7 @@ func (h *SkillsHandler) rescanAndUpdate() (updated int, results []depResult) {
|
||||
// handleRescanDeps re-checks dependencies for all skills (including archived) and updates their status.
|
||||
func (h *SkillsHandler) handleRescanDeps(w http.ResponseWriter, r *http.Request) {
|
||||
updated, results := h.rescanAndUpdate()
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"updated": updated,
|
||||
"results": results,
|
||||
})
|
||||
|
||||
@@ -30,7 +30,7 @@ func (h *SkillsHandler) handleListAgentSkills(w http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"skills": skills})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"skills": skills})
|
||||
}
|
||||
|
||||
func (h *SkillsHandler) handleGrantAgent(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -189,7 +189,7 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Info("skill uploaded", "id", id, "slug", slug, "version", version, "size", header.Size)
|
||||
|
||||
// Scan and check dependencies
|
||||
response := map[string]interface{}{
|
||||
response := map[string]any{
|
||||
"id": id,
|
||||
"slug": slug,
|
||||
"version": version,
|
||||
|
||||
@@ -35,7 +35,7 @@ func (h *SkillsHandler) handleListVersions(w http.ResponseWriter, r *http.Reques
|
||||
slugDir := filepath.Join(h.baseDir, slug)
|
||||
entries, err := os.ReadDir(slugDir)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"versions": []int{currentVersion},
|
||||
"current": currentVersion,
|
||||
})
|
||||
@@ -58,7 +58,7 @@ func (h *SkillsHandler) handleListVersions(w http.ResponseWriter, r *http.Reques
|
||||
versions = []int{currentVersion}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"versions": versions,
|
||||
"current": currentVersion,
|
||||
})
|
||||
@@ -139,7 +139,7 @@ func (h *SkillsHandler) handleListFiles(w http.ResponseWriter, r *http.Request)
|
||||
if files == nil {
|
||||
files = []fileEntry{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"files": files})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"files": files})
|
||||
}
|
||||
|
||||
// handleReadFile reads a single file from a skill version directory.
|
||||
@@ -212,7 +212,7 @@ func (h *SkillsHandler) handleReadFile(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"content": string(data),
|
||||
"path": relPath,
|
||||
"size": info.Size(),
|
||||
|
||||
+146
-48
@@ -1,11 +1,17 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/skills"
|
||||
@@ -17,6 +23,14 @@ import (
|
||||
type StorageHandler struct {
|
||||
baseDir string // resolved absolute path to ~/.goclaw/
|
||||
token string
|
||||
|
||||
// sizeCache caches the total storage size for 60 minutes.
|
||||
sizeCache struct {
|
||||
mu sync.Mutex
|
||||
total int64
|
||||
files int
|
||||
cachedAt time.Time
|
||||
}
|
||||
}
|
||||
|
||||
// NewStorageHandler creates a handler for workspace storage management.
|
||||
@@ -29,6 +43,7 @@ func (h *StorageHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /v1/storage/files", h.auth(h.handleList))
|
||||
mux.HandleFunc("GET /v1/storage/files/{path...}", h.auth(h.handleRead))
|
||||
mux.HandleFunc("DELETE /v1/storage/files/{path...}", h.auth(h.handleDelete))
|
||||
mux.HandleFunc("GET /v1/storage/size", h.auth(h.handleSize))
|
||||
}
|
||||
|
||||
func (h *StorageHandler) auth(next http.HandlerFunc) http.HandlerFunc {
|
||||
@@ -51,8 +66,8 @@ var protectedDirs = []string{"skills", "skills-store"}
|
||||
|
||||
func isProtectedPath(rel string) bool {
|
||||
top := rel
|
||||
if i := strings.IndexByte(rel, filepath.Separator); i >= 0 {
|
||||
top = rel[:i]
|
||||
if before, _, ok := strings.Cut(rel, "/"); ok {
|
||||
top = before
|
||||
}
|
||||
// Also handle forward slash on all platforms
|
||||
if i := strings.IndexByte(top, '/'); i >= 0 {
|
||||
@@ -66,8 +81,10 @@ func isProtectedPath(rel string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// handleList lists all files and directories under ~/.goclaw/.
|
||||
// Optional query param ?path= scopes the listing to a subtree.
|
||||
// handleList lists files and directories under ~/.goclaw/ with depth limiting.
|
||||
// Query params:
|
||||
// - ?path= scopes the listing to a subtree
|
||||
// - ?depth= max depth to walk (default 3, max 20)
|
||||
func (h *StorageHandler) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
locale := extractLocale(r)
|
||||
subPath := r.URL.Query().Get("path")
|
||||
@@ -77,6 +94,13 @@ func (h *StorageHandler) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
maxDepth := 3
|
||||
if d := r.URL.Query().Get("depth"); d != "" {
|
||||
if v, err := strconv.Atoi(d); err == nil && v >= 1 && v <= 20 {
|
||||
maxDepth = v
|
||||
}
|
||||
}
|
||||
|
||||
rootDir := h.baseDir
|
||||
if subPath != "" {
|
||||
rootDir = filepath.Join(h.baseDir, filepath.Clean(subPath))
|
||||
@@ -88,18 +112,15 @@ func (h *StorageHandler) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
type fileEntry struct {
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
IsDir bool `json:"isDir"`
|
||||
Size int64 `json:"size"`
|
||||
TotalSize int64 `json:"totalSize"` // recursive size for directories
|
||||
Protected bool `json:"protected"` // true if deletion is blocked
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
IsDir bool `json:"isDir"`
|
||||
Size int64 `json:"size"`
|
||||
HasChildren bool `json:"hasChildren,omitempty"`
|
||||
Protected bool `json:"protected"`
|
||||
}
|
||||
|
||||
// Compute directory sizes via a two-pass approach:
|
||||
// 1. Walk and collect entries + accumulate sizes per dir path
|
||||
var entries []fileEntry
|
||||
dirSizes := make(map[string]int64)
|
||||
|
||||
filepath.WalkDir(rootDir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
@@ -123,6 +144,15 @@ func (h *StorageHandler) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Calculate depth relative to rootDir
|
||||
relToRoot, _ := filepath.Rel(rootDir, path)
|
||||
depth := strings.Count(relToRoot, string(filepath.Separator)) + 1
|
||||
|
||||
// At depth boundary: record dir but don't descend
|
||||
if d.IsDir() && depth > maxDepth {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
entry := fileEntry{
|
||||
Path: rel,
|
||||
Name: d.Name(),
|
||||
@@ -132,16 +162,13 @@ func (h *StorageHandler) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
if !d.IsDir() {
|
||||
if info, err := d.Info(); err == nil {
|
||||
entry.Size = info.Size()
|
||||
// Accumulate file size into all parent directories
|
||||
parent := filepath.Dir(rel)
|
||||
for parent != "." && parent != "" {
|
||||
dirSizes[parent] += info.Size()
|
||||
parent = filepath.Dir(parent)
|
||||
}
|
||||
// Also accumulate to root if listing from base
|
||||
if subPath == "" {
|
||||
dirSizes["."] += info.Size()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For directories at max depth, check if they have children
|
||||
if d.IsDir() && depth == maxDepth {
|
||||
if dirEntries, err := os.ReadDir(path); err == nil && len(dirEntries) > 0 {
|
||||
entry.HasChildren = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,37 +177,92 @@ func (h *StorageHandler) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
return nil
|
||||
})
|
||||
|
||||
// 2. Assign totalSize to directory entries
|
||||
for i := range entries {
|
||||
if entries[i].IsDir {
|
||||
entries[i].TotalSize = dirSizes[entries[i].Path]
|
||||
}
|
||||
}
|
||||
|
||||
if entries == nil {
|
||||
entries = []fileEntry{}
|
||||
}
|
||||
|
||||
// Calculate total size of the root being listed
|
||||
var totalSize int64
|
||||
if subPath == "" {
|
||||
totalSize = dirSizes["."]
|
||||
} else {
|
||||
rel, _ := filepath.Rel(h.baseDir, rootDir)
|
||||
totalSize = dirSizes[rel]
|
||||
// If rootDir is the listed subtree, also sum direct files
|
||||
for _, e := range entries {
|
||||
if !e.IsDir && filepath.Dir(e.Path) == rel {
|
||||
totalSize += e.Size
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"files": entries,
|
||||
"baseDir": h.baseDir,
|
||||
})
|
||||
}
|
||||
|
||||
// sizeCacheTTL is how long storage size calculations are cached.
|
||||
const sizeCacheTTL = 60 * time.Minute
|
||||
|
||||
// handleSize streams the total storage size via SSE.
|
||||
// Cached for 60 minutes; returns cached result immediately if valid.
|
||||
func (h *StorageHandler) handleSize(w http.ResponseWriter, r *http.Request) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
locale := extractLocale(r)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgStreamingNotSupported)})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"files": entries,
|
||||
"totalSize": totalSize,
|
||||
"baseDir": h.baseDir,
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
// Check cache
|
||||
h.sizeCache.mu.Lock()
|
||||
if !h.sizeCache.cachedAt.IsZero() && time.Since(h.sizeCache.cachedAt) < sizeCacheTTL {
|
||||
total := h.sizeCache.total
|
||||
files := h.sizeCache.files
|
||||
h.sizeCache.mu.Unlock()
|
||||
writeSizeEvent(w, flusher, map[string]any{"total": total, "files": files, "done": true, "cached": true})
|
||||
return
|
||||
}
|
||||
h.sizeCache.mu.Unlock()
|
||||
|
||||
// Walk and stream progress
|
||||
var total int64
|
||||
var fileCount int
|
||||
lastFlush := time.Now()
|
||||
|
||||
filepath.WalkDir(h.baseDir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
// Check client disconnect
|
||||
if r.Context().Err() != nil {
|
||||
return filepath.SkipAll
|
||||
}
|
||||
if d.Type()&os.ModeSymlink != 0 {
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(h.baseDir, path)
|
||||
if skills.IsSystemArtifact(rel) {
|
||||
return nil
|
||||
}
|
||||
if info, err := d.Info(); err == nil {
|
||||
total += info.Size()
|
||||
fileCount++
|
||||
}
|
||||
// Emit progress every 50 files or 200ms
|
||||
if fileCount%50 == 0 || time.Since(lastFlush) > 200*time.Millisecond {
|
||||
writeSizeEvent(w, flusher, map[string]any{"current": total, "files": fileCount})
|
||||
lastFlush = time.Now()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// Update cache
|
||||
h.sizeCache.mu.Lock()
|
||||
h.sizeCache.total = total
|
||||
h.sizeCache.files = fileCount
|
||||
h.sizeCache.cachedAt = time.Now()
|
||||
h.sizeCache.mu.Unlock()
|
||||
|
||||
// Send final event
|
||||
writeSizeEvent(w, flusher, map[string]any{"total": total, "files": fileCount, "done": true, "cached": false})
|
||||
}
|
||||
|
||||
func writeSizeEvent(w http.ResponseWriter, flusher http.Flusher, data map[string]any) {
|
||||
jsonData, _ := json.Marshal(data)
|
||||
fmt.Fprintf(w, "data: %s\n\n", jsonData)
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
// handleRead reads a single file's content by relative path.
|
||||
@@ -221,7 +303,23 @@ func (h *StorageHandler) handleRead(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
// Raw mode: serve the file with its native content type (for images, downloads, etc.)
|
||||
if r.URL.Query().Get("raw") == "true" {
|
||||
ct := mime.TypeByExtension(filepath.Ext(absPath))
|
||||
if ct == "" {
|
||||
ct = http.DetectContentType(data)
|
||||
}
|
||||
w.Header().Set("Content-Type", ct)
|
||||
w.Header().Set("Cache-Control", "private, max-age=300")
|
||||
if r.URL.Query().Get("download") == "true" {
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filepath.Base(absPath)))
|
||||
}
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10))
|
||||
w.Write(data)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"content": string(data),
|
||||
"path": relPath,
|
||||
"size": info.Size(),
|
||||
|
||||
@@ -115,7 +115,7 @@ func (s *AgentSummoner) generateFiles(ctx context.Context, providerName, model,
|
||||
{Role: "user", Content: prompt},
|
||||
},
|
||||
Model: model,
|
||||
Options: map[string]interface{}{
|
||||
Options: map[string]any{
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7,
|
||||
providers.OptSessionKey: summonSessionKey,
|
||||
@@ -201,7 +201,7 @@ func (s *AgentSummoner) emitEvent(agentID uuid.UUID, eventType, fileName, errMsg
|
||||
if s.msgBus == nil {
|
||||
return
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
payload := map[string]any{
|
||||
"type": eventType,
|
||||
"agent_id": agentID.String(),
|
||||
}
|
||||
|
||||
@@ -27,15 +27,15 @@ func NewToolsInvokeHandler(registry *tools.Registry, token string, agentStore st
|
||||
}
|
||||
|
||||
type toolsInvokeRequest struct {
|
||||
Tool string `json:"tool"`
|
||||
Action string `json:"action,omitempty"`
|
||||
Args map[string]interface{} `json:"args"`
|
||||
SessionKey string `json:"sessionKey,omitempty"`
|
||||
AgentID string `json:"agentId,omitempty"`
|
||||
DryRun bool `json:"dryRun,omitempty"`
|
||||
Channel string `json:"channel,omitempty"` // tool context: channel name
|
||||
ChatID string `json:"chatId,omitempty"` // tool context: chat ID
|
||||
PeerKind string `json:"peerKind,omitempty"` // tool context: "direct" or "group"
|
||||
Tool string `json:"tool"`
|
||||
Action string `json:"action,omitempty"`
|
||||
Args map[string]any `json:"args"`
|
||||
SessionKey string `json:"sessionKey,omitempty"`
|
||||
AgentID string `json:"agentId,omitempty"`
|
||||
DryRun bool `json:"dryRun,omitempty"`
|
||||
Channel string `json:"channel,omitempty"` // tool context: channel name
|
||||
ChatID string `json:"chatId,omitempty"` // tool context: chat ID
|
||||
PeerKind string `json:"peerKind,omitempty"` // tool context: "direct" or "group"
|
||||
}
|
||||
|
||||
func (h *ToolsInvokeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -75,7 +75,7 @@ func (h *ToolsInvokeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"tool": req.Tool,
|
||||
"description": tool.Description(),
|
||||
"parameters": tool.Parameters(),
|
||||
@@ -116,7 +116,7 @@ func (h *ToolsInvokeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Execute the tool
|
||||
args := req.Args
|
||||
if args == nil {
|
||||
args = make(map[string]interface{})
|
||||
args = make(map[string]any)
|
||||
}
|
||||
|
||||
// If action is specified, add it to args
|
||||
@@ -132,11 +132,11 @@ func (h *ToolsInvokeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"result": map[string]any{
|
||||
"output": result.ForLLM,
|
||||
"forUser": result.ForUser,
|
||||
"metadata": map[string]interface{}{},
|
||||
"metadata": map[string]any{},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -144,7 +144,7 @@ func (h *ToolsInvokeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
func writeToolError(w http.ResponseWriter, status int, code, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"error": map[string]string{
|
||||
"code": code,
|
||||
"message": message,
|
||||
|
||||
@@ -93,7 +93,7 @@ func (h *TracesHandler) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
total, _ := h.tracing.CountTraces(r.Context(), opts)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"traces": traces,
|
||||
"total": total,
|
||||
"limit": opts.Limit,
|
||||
@@ -122,7 +122,7 @@ func (h *TracesHandler) handleGet(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"trace": trace,
|
||||
"spans": spans,
|
||||
})
|
||||
@@ -179,7 +179,7 @@ func (h *TracesHandler) handleExport(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
payload := struct {
|
||||
ExportedAt time.Time `json:"exported_at"`
|
||||
ExportedAt time.Time `json:"exported_at"`
|
||||
traceExportEntry
|
||||
}{
|
||||
ExportedAt: time.Now().UTC(),
|
||||
|
||||
@@ -43,6 +43,30 @@ export class HttpClient {
|
||||
return res.blob();
|
||||
}
|
||||
|
||||
/** Fetch a streaming response (SSE). Returns the raw Response for manual reading. */
|
||||
async streamFetch(path: string, signal?: AbortSignal): Promise<Response> {
|
||||
const res = await fetch(this.buildUrl(path), {
|
||||
method: "GET",
|
||||
headers: this.authHeaders(),
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) throw new ApiError("HTTP_ERROR", res.statusText);
|
||||
return res;
|
||||
}
|
||||
|
||||
/** Build a full URL with auth token as query param (for <img> src, download links). */
|
||||
rawUrl(path: string, params?: Record<string, string>): string {
|
||||
return this.buildUrl(path, params);
|
||||
}
|
||||
|
||||
/** Fetch raw blob with auth headers (for images, binary files). */
|
||||
async fetchBlob(path: string, params?: Record<string, string>): Promise<Blob> {
|
||||
const url = this.buildUrl(path, params);
|
||||
const res = await fetch(url, { method: "GET", headers: this.authHeaders() });
|
||||
if (!res.ok) throw new ApiError("HTTP_ERROR", res.statusText);
|
||||
return res.blob();
|
||||
}
|
||||
|
||||
async upload<T>(path: string, formData: FormData): Promise<T> {
|
||||
const headers: Record<string, string> = {};
|
||||
const token = this.getToken();
|
||||
@@ -77,10 +101,9 @@ export class HttpClient {
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
const h: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
/** Auth-only headers (no Content-Type), for SSE / blob requests. */
|
||||
private authHeaders(): Record<string, string> {
|
||||
const h: Record<string, string> = {};
|
||||
const token = this.getToken();
|
||||
if (token) h["Authorization"] = `Bearer ${token}`;
|
||||
const userId = this.getUserId();
|
||||
@@ -88,6 +111,10 @@ export class HttpClient {
|
||||
return h;
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
return { "Content-Type": "application/json", ...this.authHeaders() };
|
||||
}
|
||||
|
||||
private async request<T>(url: string, init: RequestInit): Promise<T> {
|
||||
let res: Response;
|
||||
try {
|
||||
|
||||
@@ -2,19 +2,21 @@ import { useTranslation } from "react-i18next";
|
||||
import { useAuthStore } from "@/stores/use-auth-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ConnectionStatus() {
|
||||
export function ConnectionStatus({ collapsed }: { collapsed?: boolean }) {
|
||||
const { t } = useTranslation("common");
|
||||
const connected = useAuthStore((s) => s.connected);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground overflow-hidden">
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
"h-2 w-2 shrink-0 rounded-full",
|
||||
connected ? "bg-green-500" : "bg-red-500",
|
||||
)}
|
||||
/>
|
||||
<span>{connected ? t("connected") : t("disconnected")}</span>
|
||||
{!collapsed && (
|
||||
<span className="truncate">{connected ? t("connected") : t("disconnected")}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -117,8 +117,8 @@ export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) {
|
||||
</nav>
|
||||
|
||||
{/* Footer: connection status */}
|
||||
<div className="border-t px-4 py-3">
|
||||
<ConnectionStatus />
|
||||
<div className={cn("border-t py-3", collapsed ? "px-2 flex justify-center" : "px-4")}>
|
||||
<ConnectionStatus collapsed={collapsed} />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatSize, type TreeNode } from "@/lib/file-helpers";
|
||||
import { Download } from "lucide-react";
|
||||
import { formatSize, sizeBadgeVariant, type TreeNode } from "@/lib/file-helpers";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FileTreePanel } from "@/components/shared/file-tree";
|
||||
import { FileContentPanel } from "@/components/shared/file-viewers";
|
||||
|
||||
@@ -14,6 +17,28 @@ function useIsMobile(breakpoint = 640) {
|
||||
return mobile;
|
||||
}
|
||||
|
||||
/** File size badge + download button row. */
|
||||
function FileActions({
|
||||
size,
|
||||
onDownload,
|
||||
}: {
|
||||
size: number;
|
||||
onDownload?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 shrink-0 ml-auto">
|
||||
<Badge variant={sizeBadgeVariant(size)} className="text-[10px] px-1.5 py-0">
|
||||
{formatSize(size)}
|
||||
</Badge>
|
||||
{onDownload && (
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={onDownload} title="Download">
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileBrowser({
|
||||
tree,
|
||||
filesLoading,
|
||||
@@ -22,6 +47,9 @@ export function FileBrowser({
|
||||
contentLoading,
|
||||
fileContent,
|
||||
onDelete,
|
||||
onLoadMore,
|
||||
onDownload,
|
||||
fetchBlob,
|
||||
showSize,
|
||||
}: {
|
||||
tree: TreeNode[];
|
||||
@@ -31,6 +59,9 @@ export function FileBrowser({
|
||||
contentLoading: boolean;
|
||||
fileContent: { content: string; path: string; size: number } | null;
|
||||
onDelete?: (path: string, isDir: boolean) => void;
|
||||
onLoadMore?: (path: string) => void;
|
||||
onDownload?: (path: string) => void;
|
||||
fetchBlob?: (path: string) => Promise<Blob>;
|
||||
showSize?: boolean;
|
||||
}) {
|
||||
const isMobile = useIsMobile();
|
||||
@@ -74,7 +105,7 @@ export function FileBrowser({
|
||||
<div className="flex-1 flex flex-col border rounded-md overflow-hidden min-h-0">
|
||||
{mobileShowTree ? (
|
||||
<div className="flex-1 overflow-y-auto bg-muted/20 py-1">
|
||||
<FileTreePanel tree={tree} filesLoading={filesLoading} activePath={activePath} onSelect={handleSelect} onDelete={onDelete} showSize={showSize} />
|
||||
<FileTreePanel tree={tree} filesLoading={filesLoading} activePath={activePath} onSelect={handleSelect} onDelete={onDelete} onLoadMore={onLoadMore} showSize={showSize} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
@@ -89,12 +120,12 @@ export function FileBrowser({
|
||||
{fileContent && (
|
||||
<>
|
||||
<span className="font-mono truncate">{fileContent.path}</span>
|
||||
<span className="shrink-0 ml-auto">{formatSize(fileContent.size)}</span>
|
||||
<FileActions size={fileContent.size} onDownload={onDownload ? () => onDownload(fileContent.path) : undefined} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-3 min-h-0">
|
||||
<FileContentPanel fileContent={fileContent} contentLoading={contentLoading} />
|
||||
<FileContentPanel fileContent={fileContent} contentLoading={contentLoading} fetchBlob={fetchBlob} onDownload={onDownload} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -106,7 +137,7 @@ export function FileBrowser({
|
||||
return (
|
||||
<div ref={containerRef} className="flex-1 flex border rounded-md overflow-hidden min-h-0">
|
||||
<div className="overflow-y-auto bg-muted/20 py-1 shrink-0" style={{ width: treeWidth }}>
|
||||
<FileTreePanel tree={tree} filesLoading={filesLoading} activePath={activePath} onSelect={handleSelect} onDelete={onDelete} showSize={showSize} />
|
||||
<FileTreePanel tree={tree} filesLoading={filesLoading} activePath={activePath} onSelect={handleSelect} onDelete={onDelete} onLoadMore={onLoadMore} showSize={showSize} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -118,11 +149,11 @@ export function FileBrowser({
|
||||
{fileContent && (
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground border-b px-3 py-2 shrink-0">
|
||||
<span className="font-mono truncate">{fileContent.path}</span>
|
||||
<span className="shrink-0 ml-2">{formatSize(fileContent.size)}</span>
|
||||
<FileActions size={fileContent.size} onDownload={onDownload ? () => onDownload(fileContent.path) : undefined} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-auto p-3 min-h-0">
|
||||
<FileContentPanel fileContent={fileContent} contentLoading={contentLoading} />
|
||||
<FileContentPanel fileContent={fileContent} contentLoading={contentLoading} fetchBlob={fetchBlob} onDownload={onDownload} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,17 +6,52 @@ import {
|
||||
FileText,
|
||||
FileCode2,
|
||||
File,
|
||||
FileImage,
|
||||
FileJson2,
|
||||
FileSpreadsheet,
|
||||
FileTerminal,
|
||||
FileArchive,
|
||||
FileVideo,
|
||||
FileAudio,
|
||||
FileCog,
|
||||
FileType,
|
||||
FileLock,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { extOf, CODE_EXTENSIONS, formatSize, type TreeNode } from "@/lib/file-helpers";
|
||||
import { extOf, CODE_EXTENSIONS, IMAGE_EXTENSIONS, formatSize, type TreeNode } from "@/lib/file-helpers";
|
||||
|
||||
const cls = "h-4 w-4 shrink-0";
|
||||
|
||||
function FileIcon({ name }: { name: string }) {
|
||||
const ext = extOf(name);
|
||||
if (ext === "md") return <FileText className="h-4 w-4 shrink-0 text-blue-500" />;
|
||||
if (CODE_EXTENSIONS.has(ext)) return <FileCode2 className="h-4 w-4 shrink-0 text-orange-500" />;
|
||||
return <File className="h-4 w-4 shrink-0 text-muted-foreground" />;
|
||||
|
||||
// Markdown
|
||||
if (ext === "md" || ext === "mdx") return <FileText className={`${cls} text-blue-500`} />;
|
||||
// JSON / YAML config
|
||||
if (ext === "json" || ext === "json5") return <FileJson2 className={`${cls} text-yellow-600`} />;
|
||||
if (ext === "yaml" || ext === "yml" || ext === "toml") return <FileCog className={`${cls} text-purple-500`} />;
|
||||
// Spreadsheet / CSV
|
||||
if (ext === "csv") return <FileSpreadsheet className={`${cls} text-green-600`} />;
|
||||
// Shell / terminal
|
||||
if (ext === "sh" || ext === "bash" || ext === "zsh") return <FileTerminal className={`${cls} text-lime-600`} />;
|
||||
// Images
|
||||
if (IMAGE_EXTENSIONS.has(ext)) return <FileImage className={`${cls} text-emerald-500`} />;
|
||||
// Video
|
||||
if (ext === "mp4" || ext === "webm" || ext === "mov" || ext === "avi" || ext === "mkv") return <FileVideo className={`${cls} text-pink-500`} />;
|
||||
// Audio
|
||||
if (ext === "mp3" || ext === "wav" || ext === "ogg" || ext === "flac" || ext === "m4a") return <FileAudio className={`${cls} text-violet-500`} />;
|
||||
// Archive
|
||||
if (ext === "zip" || ext === "tar" || ext === "gz" || ext === "rar" || ext === "7z" || ext === "bz2") return <FileArchive className={`${cls} text-amber-600`} />;
|
||||
// Font
|
||||
if (ext === "ttf" || ext === "otf" || ext === "woff" || ext === "woff2") return <FileType className={`${cls} text-slate-500`} />;
|
||||
// Env / secrets
|
||||
if (ext === "env" || ext === "pem" || ext === "key" || ext === "crt") return <FileLock className={`${cls} text-red-500`} />;
|
||||
// Code (generic)
|
||||
if (CODE_EXTENSIONS.has(ext)) return <FileCode2 className={`${cls} text-orange-500`} />;
|
||||
// Default
|
||||
return <File className={`${cls} text-muted-foreground`} />;
|
||||
}
|
||||
|
||||
export function TreeItem({
|
||||
@@ -25,6 +60,7 @@ export function TreeItem({
|
||||
activePath,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onLoadMore,
|
||||
showSize,
|
||||
}: {
|
||||
node: TreeNode;
|
||||
@@ -32,11 +68,21 @@ export function TreeItem({
|
||||
activePath: string | null;
|
||||
onSelect: (path: string) => void;
|
||||
onDelete?: (path: string, isDir: boolean) => void;
|
||||
onLoadMore?: (path: string) => void;
|
||||
showSize?: boolean;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(depth === 0);
|
||||
const isActive = activePath === node.path;
|
||||
|
||||
const handleToggle = () => {
|
||||
const willExpand = !expanded;
|
||||
setExpanded(willExpand);
|
||||
// Lazy load: if expanding a folder with hasChildren but no loaded children
|
||||
if (willExpand && node.isDir && node.hasChildren && node.children.length === 0 && !node.loading) {
|
||||
onLoadMore?.(node.path);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteBtn = onDelete && !node.protected && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -48,9 +94,9 @@ export function TreeItem({
|
||||
</button>
|
||||
);
|
||||
|
||||
const sizeLabel = showSize && (node.isDir ? (node.totalSize ?? 0) : node.size) > 0 && (
|
||||
const sizeLabel = showSize && (node.isDir ? 0 : node.size) > 0 && (
|
||||
<span className="ml-auto shrink-0 text-[10px] text-muted-foreground tabular-nums">
|
||||
{formatSize(node.isDir ? (node.totalSize ?? 0) : node.size)}
|
||||
{formatSize(node.size)}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -60,7 +106,7 @@ export function TreeItem({
|
||||
<div
|
||||
className="group/tree-item flex w-full items-center gap-1 rounded px-2 py-1 text-left text-sm hover:bg-accent cursor-pointer"
|
||||
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<ChevronRight
|
||||
className={`h-3 w-3 shrink-0 transition-transform ${expanded ? "rotate-90" : ""}`}
|
||||
@@ -71,6 +117,7 @@ export function TreeItem({
|
||||
<Folder className="h-4 w-4 shrink-0 text-yellow-600" />
|
||||
)}
|
||||
<span className="truncate">{node.name}</span>
|
||||
{node.loading && <Loader2 className="h-3 w-3 shrink-0 animate-spin text-muted-foreground ml-1" />}
|
||||
{sizeLabel}
|
||||
{deleteBtn}
|
||||
</div>
|
||||
@@ -82,9 +129,21 @@ export function TreeItem({
|
||||
activePath={activePath}
|
||||
onSelect={onSelect}
|
||||
onDelete={onDelete}
|
||||
onLoadMore={onLoadMore}
|
||||
showSize={showSize}
|
||||
/>
|
||||
))}
|
||||
{/* Show chevron hint for loadable but not-yet-expanded empty folders */}
|
||||
{expanded && node.hasChildren && node.children.length === 0 && !node.loading && (
|
||||
<div
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
style={{ paddingLeft: `${(depth + 1) * 16 + 20}px` }}
|
||||
onClick={() => onLoadMore?.(node.path)}
|
||||
>
|
||||
<Loader2 className="h-3 w-3" />
|
||||
<span>Load more...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -111,6 +170,7 @@ export function FileTreePanel({
|
||||
activePath,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onLoadMore,
|
||||
showSize,
|
||||
}: {
|
||||
tree: TreeNode[];
|
||||
@@ -118,6 +178,7 @@ export function FileTreePanel({
|
||||
activePath: string | null;
|
||||
onSelect: (path: string) => void;
|
||||
onDelete?: (path: string, isDir: boolean) => void;
|
||||
onLoadMore?: (path: string) => void;
|
||||
showSize?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("common");
|
||||
@@ -134,7 +195,7 @@ export function FileTreePanel({
|
||||
return (
|
||||
<>
|
||||
{tree.map((node) => (
|
||||
<TreeItem key={node.path} node={node} depth={0} activePath={activePath} onSelect={onSelect} onDelete={onDelete} showSize={showSize} />
|
||||
<TreeItem key={node.path} node={node} depth={0} activePath={activePath} onSelect={onSelect} onDelete={onDelete} onLoadMore={onLoadMore} showSize={showSize} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useMemo, useRef, useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, Copy, Loader2 } from "lucide-react";
|
||||
import { Check, Copy, Download, FileQuestion, Loader2 } from "lucide-react";
|
||||
import { MarkdownRenderer } from "@/components/shared/markdown-renderer";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useClipboard } from "@/hooks/use-clipboard";
|
||||
import hljs from "highlight.js/lib/core";
|
||||
import typescript from "highlight.js/lib/languages/typescript";
|
||||
@@ -20,7 +22,16 @@ import java from "highlight.js/lib/languages/java";
|
||||
import c from "highlight.js/lib/languages/c";
|
||||
import cpp from "highlight.js/lib/languages/cpp";
|
||||
import lua from "highlight.js/lib/languages/lua";
|
||||
import { extOf, langFor, stripFrontmatter, CODE_EXTENSIONS } from "@/lib/file-helpers";
|
||||
import {
|
||||
extOf,
|
||||
langFor,
|
||||
stripFrontmatter,
|
||||
formatSize,
|
||||
sizeBadgeVariant,
|
||||
isImageFile,
|
||||
isTextFile,
|
||||
CODE_EXTENSIONS,
|
||||
} from "@/lib/file-helpers";
|
||||
|
||||
hljs.registerLanguage("typescript", typescript);
|
||||
hljs.registerLanguage("tsx", typescript);
|
||||
@@ -142,26 +153,132 @@ function CsvViewer({ content }: { content: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function FileContentBody({ path, content }: { path: string; content: string }) {
|
||||
const ext = extOf(path);
|
||||
const displayContent = ext === "md" ? stripFrontmatter(content) : content;
|
||||
function ImageViewer({ path, fetchBlob }: { path: string; fetchBlob: (path: string) => Promise<Blob> }) {
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let objectUrl: string | null = null;
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
|
||||
fetchBlob(path)
|
||||
.then((blob) => {
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setSrc(objectUrl);
|
||||
})
|
||||
.catch(() => setError(true))
|
||||
.finally(() => setLoading(false));
|
||||
|
||||
return () => {
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [path, fetchBlob]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !src) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
Failed to load image
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (ext === "md") return <MarkdownRenderer content={displayContent} />;
|
||||
if (ext === "csv") return <CsvViewer content={displayContent} />;
|
||||
if (CODE_EXTENSIONS.has(ext)) return <CodeViewer content={displayContent} language={langFor(ext)} />;
|
||||
return (
|
||||
<pre className="whitespace-pre-wrap rounded-md border bg-muted/30 p-4 text-sm">
|
||||
{displayContent}
|
||||
</pre>
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<img
|
||||
src={src}
|
||||
alt={path.split("/").pop() ?? ""}
|
||||
className="max-w-full max-h-[70vh] object-contain rounded-lg border border-border/40"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UnsupportedFileViewer({
|
||||
path,
|
||||
size,
|
||||
onDownload,
|
||||
}: {
|
||||
path: string;
|
||||
size: number;
|
||||
onDownload?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("storage");
|
||||
const fileName = path.split("/").pop() ?? path;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-4">
|
||||
<FileQuestion className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">{t("unsupportedFile")}</p>
|
||||
{onDownload && (
|
||||
<Button variant="outline" size="sm" onClick={onDownload}>
|
||||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
||||
{fileName}
|
||||
<Badge variant={sizeBadgeVariant(size)} className="text-[10px] ml-1.5">
|
||||
{formatSize(size)}
|
||||
</Badge>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileContentBody({
|
||||
path,
|
||||
content,
|
||||
size,
|
||||
fetchBlob,
|
||||
onDownload,
|
||||
}: {
|
||||
path: string;
|
||||
content: string;
|
||||
size?: number;
|
||||
fetchBlob?: (path: string) => Promise<Blob>;
|
||||
onDownload?: () => void;
|
||||
}) {
|
||||
const ext = extOf(path);
|
||||
|
||||
// Image files
|
||||
if (isImageFile(path) && fetchBlob) {
|
||||
return <ImageViewer path={path} fetchBlob={fetchBlob} />;
|
||||
}
|
||||
|
||||
// Text-based files
|
||||
if (isTextFile(path) || ext === "md" || ext === "csv" || CODE_EXTENSIONS.has(ext)) {
|
||||
const displayContent = ext === "md" ? stripFrontmatter(content) : content;
|
||||
if (ext === "md") return <MarkdownRenderer content={displayContent} />;
|
||||
if (ext === "csv") return <CsvViewer content={displayContent} />;
|
||||
if (CODE_EXTENSIONS.has(ext)) return <CodeViewer content={displayContent} language={langFor(ext)} />;
|
||||
return (
|
||||
<pre className="whitespace-pre-wrap rounded-md border bg-muted/30 p-4 text-sm">
|
||||
{displayContent}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
// Unsupported files
|
||||
return <UnsupportedFileViewer path={path} size={size ?? 0} onDownload={onDownload} />;
|
||||
}
|
||||
|
||||
export function FileContentPanel({
|
||||
fileContent,
|
||||
contentLoading,
|
||||
fetchBlob,
|
||||
onDownload,
|
||||
}: {
|
||||
fileContent: { content: string; path: string; size: number } | null;
|
||||
contentLoading: boolean;
|
||||
fetchBlob?: (path: string) => Promise<Blob>;
|
||||
onDownload?: (path: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("common");
|
||||
if (contentLoading) {
|
||||
@@ -172,7 +289,15 @@ export function FileContentPanel({
|
||||
);
|
||||
}
|
||||
if (fileContent) {
|
||||
return <FileContentBody path={fileContent.path} content={fileContent.content} />;
|
||||
return (
|
||||
<FileContentBody
|
||||
path={fileContent.path}
|
||||
content={fileContent.content}
|
||||
size={fileContent.size}
|
||||
fetchBlob={fetchBlob}
|
||||
onDownload={onDownload ? () => onDownload(fileContent.path) : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
description?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
"title": "Storage",
|
||||
"description": "Workspace file browser",
|
||||
"descriptionWithPath": "{{path}} — {{size}}",
|
||||
"sizeCacheInfo": "Storage size is cached for 60 minutes",
|
||||
"unsupportedFile": "This file type is not supported for preview",
|
||||
"download": "Download",
|
||||
"delete": {
|
||||
"fileTitle": "Delete file",
|
||||
"folderTitle": "Delete folder",
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
"title": "Lưu trữ",
|
||||
"description": "Trình duyệt tệp workspace",
|
||||
"descriptionWithPath": "{{path}} — {{size}}",
|
||||
"sizeCacheInfo": "Dung lượng lưu trữ được cache trong 60 phút",
|
||||
"unsupportedFile": "Loại tệp này chưa được hỗ trợ xem trước",
|
||||
"download": "Tải về",
|
||||
"delete": {
|
||||
"fileTitle": "Xóa tệp",
|
||||
"folderTitle": "Xóa thư mục",
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
"title": "存储",
|
||||
"description": "工作区文件浏览器",
|
||||
"descriptionWithPath": "{{path}} — {{size}}",
|
||||
"sizeCacheInfo": "存储大小缓存60分钟",
|
||||
"unsupportedFile": "此文件类型不支持预览",
|
||||
"download": "下载",
|
||||
"delete": {
|
||||
"fileTitle": "删除文件",
|
||||
"folderTitle": "删除文件夹",
|
||||
|
||||
@@ -3,7 +3,8 @@ export interface TreeNode {
|
||||
path: string;
|
||||
isDir: boolean;
|
||||
size: number;
|
||||
totalSize?: number;
|
||||
hasChildren?: boolean;
|
||||
loading?: boolean;
|
||||
protected?: boolean;
|
||||
children: TreeNode[];
|
||||
}
|
||||
@@ -14,11 +15,27 @@ export const CODE_EXTENSIONS = new Set([
|
||||
"c", "cpp", "h", "xml", "graphql", "proto", "lua", "zig", "env",
|
||||
]);
|
||||
|
||||
export const IMAGE_EXTENSIONS = new Set([
|
||||
"jpg", "jpeg", "png", "gif", "webp", "svg", "ico", "bmp",
|
||||
]);
|
||||
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
...CODE_EXTENSIONS, ...["md", "csv", "txt", "log", "cfg", "ini", "conf"],
|
||||
]);
|
||||
|
||||
export function extOf(name: string): string {
|
||||
const dot = name.lastIndexOf(".");
|
||||
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : "";
|
||||
}
|
||||
|
||||
export function isImageFile(name: string): boolean {
|
||||
return IMAGE_EXTENSIONS.has(extOf(name));
|
||||
}
|
||||
|
||||
export function isTextFile(name: string): boolean {
|
||||
return TEXT_EXTENSIONS.has(extOf(name));
|
||||
}
|
||||
|
||||
export function langFor(ext: string): string {
|
||||
const map: Record<string, string> = {
|
||||
ts: "typescript", tsx: "tsx", js: "javascript", jsx: "jsx",
|
||||
@@ -32,7 +49,22 @@ export function langFor(ext: string): string {
|
||||
return map[ext] ?? ext;
|
||||
}
|
||||
|
||||
interface TreeInput { path: string; name: string; isDir: boolean; size: number; totalSize?: number; protected?: boolean }
|
||||
/** Returns badge variant based on file size. */
|
||||
export function sizeBadgeVariant(bytes: number): "outline" | "info" | "warning" | "destructive" {
|
||||
if (bytes < 100 * 1024) return "outline";
|
||||
if (bytes < 1024 * 1024) return "info";
|
||||
if (bytes < 10 * 1024 * 1024) return "warning";
|
||||
return "destructive";
|
||||
}
|
||||
|
||||
interface TreeInput {
|
||||
path: string;
|
||||
name: string;
|
||||
isDir: boolean;
|
||||
size: number;
|
||||
hasChildren?: boolean;
|
||||
protected?: boolean;
|
||||
}
|
||||
|
||||
export function buildTree(files: TreeInput[]): TreeNode[] {
|
||||
const root: TreeNode[] = [];
|
||||
@@ -49,7 +81,7 @@ export function buildTree(files: TreeInput[]): TreeNode[] {
|
||||
path: f.path,
|
||||
isDir: f.isDir,
|
||||
size: f.size,
|
||||
totalSize: f.totalSize,
|
||||
hasChildren: f.hasChildren,
|
||||
protected: f.protected,
|
||||
children: [],
|
||||
};
|
||||
@@ -82,6 +114,33 @@ export function buildTree(files: TreeInput[]): TreeNode[] {
|
||||
return root;
|
||||
}
|
||||
|
||||
/** Merges a loaded subtree into an existing tree, replacing the target folder's children. */
|
||||
export function mergeSubtree(tree: TreeNode[], parentPath: string, newChildren: TreeInput[]): TreeNode[] {
|
||||
const childTree = buildTree(newChildren);
|
||||
|
||||
const merge = (nodes: TreeNode[]): TreeNode[] =>
|
||||
nodes.map((node) => {
|
||||
if (node.path === parentPath && node.isDir) {
|
||||
return { ...node, children: childTree, hasChildren: false, loading: false };
|
||||
}
|
||||
if (node.children.length > 0) {
|
||||
return { ...node, children: merge(node.children) };
|
||||
}
|
||||
return node;
|
||||
});
|
||||
|
||||
return merge(tree);
|
||||
}
|
||||
|
||||
/** Marks a node as loading in the tree (immutable update). */
|
||||
export function setNodeLoading(tree: TreeNode[], path: string, loading: boolean): TreeNode[] {
|
||||
return tree.map((node) => {
|
||||
if (node.path === path) return { ...node, loading };
|
||||
if (node.children.length > 0) return { ...node, children: setNodeLoading(node.children, path, loading) };
|
||||
return node;
|
||||
});
|
||||
}
|
||||
|
||||
export function stripFrontmatter(content: string): string {
|
||||
if (!content.startsWith("---")) return content;
|
||||
const end = content.indexOf("---", 3);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
|
||||
export interface StorageFile {
|
||||
@@ -6,13 +6,12 @@ export interface StorageFile {
|
||||
name: string;
|
||||
isDir: boolean;
|
||||
size: number;
|
||||
totalSize: number;
|
||||
hasChildren?: boolean;
|
||||
protected: boolean;
|
||||
}
|
||||
|
||||
interface StorageListResponse {
|
||||
files: StorageFile[];
|
||||
totalSize: number;
|
||||
baseDir: string;
|
||||
}
|
||||
|
||||
@@ -25,7 +24,6 @@ interface StorageFileContent {
|
||||
export function useStorage() {
|
||||
const http = useHttp();
|
||||
const [files, setFiles] = useState<StorageFile[]>([]);
|
||||
const [totalSize, setTotalSize] = useState(0);
|
||||
const [baseDir, setBaseDir] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -34,13 +32,17 @@ export function useStorage() {
|
||||
try {
|
||||
const res = await http.get<StorageListResponse>("/v1/storage/files");
|
||||
setFiles(res.files ?? []);
|
||||
setTotalSize(res.totalSize ?? 0);
|
||||
setBaseDir(res.baseDir ?? "");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [http]);
|
||||
|
||||
const loadSubtree = useCallback(async (path: string): Promise<StorageFile[]> => {
|
||||
const res = await http.get<StorageListResponse>("/v1/storage/files", { path });
|
||||
return res.files ?? [];
|
||||
}, [http]);
|
||||
|
||||
const readFile = useCallback(
|
||||
async (path: string) => {
|
||||
return http.get<StorageFileContent>(
|
||||
@@ -59,5 +61,78 @@ export function useStorage() {
|
||||
[http],
|
||||
);
|
||||
|
||||
return { files, totalSize, baseDir, loading, listFiles, readFile, deleteFile };
|
||||
/** Fetch raw file as blob (for images, downloads). */
|
||||
const fetchRawBlob = useCallback(
|
||||
(path: string, download?: boolean) => {
|
||||
const params: Record<string, string> = { raw: "true" };
|
||||
if (download) params.download = "true";
|
||||
return http.fetchBlob(`/v1/storage/files/${encodeURIComponent(path)}`, params);
|
||||
},
|
||||
[http],
|
||||
);
|
||||
|
||||
return { files, baseDir, loading, listFiles, loadSubtree, readFile, deleteFile, fetchRawBlob };
|
||||
}
|
||||
|
||||
interface SizeState {
|
||||
totalSize: number;
|
||||
fileCount: number;
|
||||
loading: boolean;
|
||||
cached: boolean;
|
||||
}
|
||||
|
||||
export function useStorageSize() {
|
||||
const http = useHttp();
|
||||
const [state, setState] = useState<SizeState>({ totalSize: 0, fileCount: 0, loading: false, cached: false });
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchSize = useCallback(async () => {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setState((s) => ({ ...s, loading: true, totalSize: 0, fileCount: 0 }));
|
||||
|
||||
try {
|
||||
const res = await http.streamFetch("/v1/storage/size", controller.signal);
|
||||
if (!res.body) {
|
||||
setState((s) => ({ ...s, loading: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
if (data.done) {
|
||||
setState({ totalSize: data.total, fileCount: data.files, loading: false, cached: !!data.cached });
|
||||
} else {
|
||||
setState((s) => ({ ...s, totalSize: data.current, fileCount: data.files }));
|
||||
}
|
||||
} catch { /* skip malformed */ }
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
setState((s) => ({ ...s, loading: false }));
|
||||
}
|
||||
}, [http]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => abortRef.current?.abort();
|
||||
}, []);
|
||||
|
||||
return { ...state, refreshSize: fetchSize };
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { Info, RefreshCw } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PageHeader } from "@/components/shared/page-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -11,36 +17,65 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { buildTree, formatSize } from "@/lib/file-helpers";
|
||||
import { buildTree, mergeSubtree, setNodeLoading, formatSize, isTextFile } from "@/lib/file-helpers";
|
||||
import { FileBrowser } from "@/components/shared/file-browser";
|
||||
import { useStorage } from "./hooks/use-storage";
|
||||
import { useStorage, useStorageSize } from "./hooks/use-storage";
|
||||
|
||||
export function StoragePage() {
|
||||
const { t } = useTranslation("storage");
|
||||
const { files, totalSize, baseDir, loading, listFiles, readFile, deleteFile } = useStorage();
|
||||
const { files, baseDir, loading, listFiles, loadSubtree, readFile, deleteFile, fetchRawBlob } = useStorage();
|
||||
const { totalSize, loading: sizeLoading, refreshSize } = useStorageSize();
|
||||
|
||||
const [tree, setTree] = useState(buildTree(files));
|
||||
const [activePath, setActivePath] = useState<string | null>(null);
|
||||
const [fileContent, setFileContent] = useState<{ content: string; path: string; size: number } | null>(null);
|
||||
const [contentLoading, setContentLoading] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<{ path: string; isDir: boolean } | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
useEffect(() => { listFiles(); }, [listFiles]);
|
||||
// Rebuild tree when files change from initial load or refresh
|
||||
useEffect(() => { setTree(buildTree(files)); }, [files]);
|
||||
|
||||
const tree = useMemo(() => buildTree(files), [files]);
|
||||
// Load file list + size on mount
|
||||
useEffect(() => { listFiles(); refreshSize(); }, [listFiles, refreshSize]);
|
||||
|
||||
const handleLoadMore = useCallback(async (path: string) => {
|
||||
// Mark node as loading
|
||||
setTree((prev) => setNodeLoading(prev, path, true));
|
||||
try {
|
||||
const children = await loadSubtree(path);
|
||||
setTree((prev) => mergeSubtree(prev, path, children));
|
||||
} catch {
|
||||
setTree((prev) => setNodeLoading(prev, path, false));
|
||||
}
|
||||
}, [loadSubtree]);
|
||||
|
||||
/** Find a file node's size from the flat files list. */
|
||||
const fileSizeMap = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
for (const f of files) if (!f.isDir) m.set(f.path, f.size);
|
||||
return m;
|
||||
}, [files]);
|
||||
|
||||
const handleSelect = useCallback(async (path: string) => {
|
||||
setActivePath(path);
|
||||
setContentLoading(true);
|
||||
try {
|
||||
const res = await readFile(path);
|
||||
setFileContent(res);
|
||||
} catch {
|
||||
setFileContent(null);
|
||||
} finally {
|
||||
setContentLoading(false);
|
||||
if (isTextFile(path)) {
|
||||
setContentLoading(true);
|
||||
try {
|
||||
const res = await readFile(path);
|
||||
setFileContent(res);
|
||||
} catch {
|
||||
setFileContent(null);
|
||||
} finally {
|
||||
setContentLoading(false);
|
||||
}
|
||||
} else {
|
||||
// For non-text files (images, binaries): don't fetch content — just set metadata.
|
||||
// ImageViewer will fetch the blob separately; UnsupportedViewer just shows size.
|
||||
const size = fileSizeMap.get(path) ?? 0;
|
||||
setFileContent({ content: "", path, size });
|
||||
}
|
||||
}, [readFile]);
|
||||
}, [readFile, fileSizeMap]);
|
||||
|
||||
const handleDeleteRequest = useCallback((path: string, isDir: boolean) => {
|
||||
setDeleteTarget({ path, isDir });
|
||||
@@ -51,7 +86,6 @@ export function StoragePage() {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteFile(deleteTarget.path);
|
||||
// Clear content if the deleted file was being viewed
|
||||
if (activePath === deleteTarget.path || (deleteTarget.isDir && activePath?.startsWith(deleteTarget.path + "/"))) {
|
||||
setActivePath(null);
|
||||
setFileContent(null);
|
||||
@@ -63,15 +97,60 @@ export function StoragePage() {
|
||||
}
|
||||
}, [deleteTarget, deleteFile, listFiles, activePath]);
|
||||
|
||||
const handleDownload = useCallback(async (path: string) => {
|
||||
try {
|
||||
const blob = await fetchRawBlob(path, true);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = path.split("/").pop() ?? "download";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch { /* silent fail */ }
|
||||
}, [fetchRawBlob]);
|
||||
|
||||
/** Fetch raw blob for image rendering (no download header). */
|
||||
const handleFetchBlob = useCallback(async (path: string) => {
|
||||
return fetchRawBlob(path, false);
|
||||
}, [fetchRawBlob]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
listFiles();
|
||||
refreshSize();
|
||||
}, [listFiles, refreshSize]);
|
||||
|
||||
const deleteName = deleteTarget?.path.split("/").pop() ?? "";
|
||||
|
||||
// Size description with cache tooltip
|
||||
const sizeDescription = useMemo(() => {
|
||||
if (!baseDir) return t("description");
|
||||
const sizeStr = sizeLoading ? `${formatSize(totalSize)}...` : formatSize(totalSize);
|
||||
return t("descriptionWithPath", { path: baseDir, size: sizeStr });
|
||||
}, [baseDir, totalSize, sizeLoading, t]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full p-4 sm:p-6">
|
||||
<PageHeader
|
||||
title={t("title")}
|
||||
description={baseDir ? t("descriptionWithPath", { path: baseDir, size: formatSize(totalSize) }) : t("description")}
|
||||
description={
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{sizeDescription}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help shrink-0" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{t("sizeCacheInfo")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</span>
|
||||
}
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={listFiles} disabled={loading}>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={loading}>
|
||||
<RefreshCw className={`h-4 w-4 mr-1.5 ${loading ? "animate-spin" : ""}`} />
|
||||
{t("common:refresh", "Refresh")}
|
||||
</Button>
|
||||
@@ -87,6 +166,9 @@ export function StoragePage() {
|
||||
contentLoading={contentLoading}
|
||||
fileContent={fileContent}
|
||||
onDelete={handleDeleteRequest}
|
||||
onLoadMore={handleLoadMore}
|
||||
onDownload={handleDownload}
|
||||
fetchBlob={handleFetchBlob}
|
||||
showSize
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user