From 88c59ca2c723604e475832d2a94c6e10b8cf7daf Mon Sep 17 00:00:00 2001 From: Ada Date: Mon, 9 Mar 2026 11:34:40 +0000 Subject: [PATCH 1/3] feat: Implement file explorer and file operations (Issue #6) - Add file_operations.go with file browser and action handlers - Implement /claude-files command to browse project files - Implement /claude-new-file command to create files - Add file action menu (view, edit, delete) - View file content with syntax highlighting - Delete files with confirmation - Extend BridgeClient with file operation methods - Register HTTP endpoints for file actions - Update help command with file operation docs Bridge server endpoints already implemented in Issue #3. Full interactive editing deferred to Issue #5. --- server/bridge_client.go | 141 +++++++++++- server/commands.go | 35 +++ server/file_operations.go | 473 ++++++++++++++++++++++++++++++++++++++ server/plugin.go | 26 +++ 4 files changed, 673 insertions(+), 2 deletions(-) create mode 100644 server/file_operations.go diff --git a/server/bridge_client.go b/server/bridge_client.go index 7c246e1..2403ef2 100644 --- a/server/bridge_client.go +++ b/server/bridge_client.go @@ -335,8 +335,8 @@ func (bc *BridgeClient) ModifyChange(sessionID, changeID, instructions string) e return nil } -// GetFileContent retrieves the full content of a file from the session's project -func (bc *BridgeClient) GetFileContent(sessionID, filename string) (string, error) { +// GetFileContentByName retrieves the full content of a file from the session's project by filename +func (bc *BridgeClient) GetFileContentByName(sessionID, filename string) (string, error) { reqBody := map[string]string{ "filename": filename, } @@ -370,3 +370,140 @@ func (bc *BridgeClient) GetFileContent(sessionID, filename string) (string, erro return result.Content, nil } + +// ListFiles retrieves the file tree for a session +func (bc *BridgeClient) ListFiles(sessionID string) ([]FileNode, error) { + resp, err := bc.httpClient.Get(fmt.Sprintf("%s/api/sessions/%s/files", bc.baseURL, sessionID)) + if err != nil { + return nil, fmt.Errorf("failed to list files: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("bridge server returned status %d: %s", resp.StatusCode, string(body)) + } + + var result struct { + Files []FileNode `json:"files"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return result.Files, nil +} + +// GetFileContent retrieves the content of a file +func (bc *BridgeClient) GetFileContent(sessionID, filePath string) (string, error) { + resp, err := bc.httpClient.Get(fmt.Sprintf("%s/api/sessions/%s/files/%s", bc.baseURL, sessionID, filePath)) + if err != nil { + return "", fmt.Errorf("failed to get file content: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("bridge server returned status %d: %s", resp.StatusCode, string(body)) + } + + var result struct { + Path string `json:"path"` + Content string `json:"content"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("failed to decode response: %w", err) + } + + return result.Content, nil +} + +// CreateFile creates a new file in the project +func (bc *BridgeClient) CreateFile(sessionID, filePath, content string) error { + reqBody := map[string]string{ + "path": filePath, + "content": content, + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + resp, err := bc.httpClient.Post( + fmt.Sprintf("%s/api/sessions/%s/files", bc.baseURL, sessionID), + "application/json", + bytes.NewBuffer(jsonData), + ) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("bridge server returned status %d: %s", resp.StatusCode, string(body)) + } + + return nil +} + +// UpdateFile updates the content of an existing file +func (bc *BridgeClient) UpdateFile(sessionID, filePath, content string) error { + reqBody := map[string]string{ + "content": content, + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequest( + http.MethodPut, + fmt.Sprintf("%s/api/sessions/%s/files/%s", bc.baseURL, sessionID, filePath), + bytes.NewBuffer(jsonData), + ) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := bc.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to update file: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("bridge server returned status %d: %s", resp.StatusCode, string(body)) + } + + return nil +} + +// DeleteFile deletes a file from the project +func (bc *BridgeClient) DeleteFile(sessionID, filePath string) error { + req, err := http.NewRequest( + http.MethodDelete, + fmt.Sprintf("%s/api/sessions/%s/files/%s", bc.baseURL, sessionID, filePath), + nil, + ) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + resp, err := bc.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to delete file: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("bridge server returned status %d: %s", resp.StatusCode, string(body)) + } + + return nil +} diff --git a/server/commands.go b/server/commands.go index fcb2c3e..5816094 100644 --- a/server/commands.go +++ b/server/commands.go @@ -15,6 +15,8 @@ const ( commandTriggerClaudeStop = "claude-stop" commandTriggerClaudeStatus = "claude-status" commandTriggerClaudeThread = "claude-thread" + commandTriggerClaudeFiles = "claude-files" + commandTriggerClaudeNewFile = "claude-new-file" commandTriggerClaudeHelp = "claude-help" ) @@ -77,6 +79,29 @@ func (p *Plugin) registerCommands() error { return err } + // Register /claude-files command + if err := p.API.RegisterCommand(&model.Command{ + Trigger: commandTriggerClaudeFiles, + AutoComplete: true, + AutoCompleteDesc: "Browse project files", + DisplayName: "Browse Files", + Description: "Open file browser for the current Claude session", + }); err != nil { + return err + } + + // Register /claude-new-file command + if err := p.API.RegisterCommand(&model.Command{ + Trigger: commandTriggerClaudeNewFile, + AutoComplete: true, + AutoCompleteDesc: "Create a new file", + AutoCompleteHint: "[file-path]", + DisplayName: "Create New File", + Description: "Create a new file in the project", + }); err != nil { + return err + } + // Register /claude-help command if err := p.API.RegisterCommand(&model.Command{ Trigger: commandTriggerClaudeHelp, @@ -112,6 +137,10 @@ func (p *Plugin) ExecuteCommand(c *plugin.Context, args *model.CommandArgs) (*mo return p.executeClaudeStatus(args), nil case commandTriggerClaudeThread: return p.executeClaudeThread(args, commandArgs), nil + case commandTriggerClaudeFiles: + return p.executeClaudeFiles(args), nil + case commandTriggerClaudeNewFile: + return p.executeClaudeNewFile(args, commandArgs), nil case commandTriggerClaudeHelp: return p.executeClaudeHelp(), nil default: @@ -398,6 +427,8 @@ func (p *Plugin) executeClaudeHelp() *model.CommandResponse { "- `/claude-stop` - Stop the current session\n" + "- `/claude-status` - Show current session status\n" + "- `/claude-thread [action]` - Add thread context to Claude (run in a thread)\n" + + "- `/claude-files` - Browse project files\n" + + "- `/claude-new-file ` - Create a new file\n" + "- `/claude-help` - Show this help message\n\n" + "**Getting Started:**\n" + "1. Start a session with `/claude-start /path/to/your/project`\n" + @@ -413,6 +444,10 @@ func (p *Plugin) executeClaudeHelp() *model.CommandResponse { "- `/claude-thread summarize` - Add context and ask Claude to summarize\n" + "- `/claude-thread implement` - Add context and ask Claude to implement\n" + "- `/claude-thread review` - Add context and ask Claude to review\n\n" + + "**File Operations:**\n" + + "- `/claude-files` - Browse and manage project files\n" + + "- `/claude-new-file src/example.ts` - Create a new file\n" + + "- Click file actions to view, edit, or delete files\n\n" + "**Configuration:**\n" + "Go to **System Console > Plugins > Claude Code** to configure settings.\n\n" + "For more information, visit: https://github.com/appsome/claude-code-mattermost-plugin" diff --git a/server/file_operations.go b/server/file_operations.go new file mode 100644 index 0000000..cef3df6 --- /dev/null +++ b/server/file_operations.go @@ -0,0 +1,473 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "path/filepath" + "strings" + + "github.com/mattermost/mattermost/server/public/model" +) + +// FileNode represents a file or directory in the file tree +type FileNode struct { + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` // "file" or "directory" + Size *int64 `json:"size,omitempty"` + Children []FileNode `json:"children,omitempty"` +} + +// FileActionType represents available file operations +type FileActionType string + +const ( + FileActionView FileActionType = "view" + FileActionEdit FileActionType = "edit" + FileActionDelete FileActionType = "delete" + FileActionDiff FileActionType = "diff" +) + +// registerFileCommands registers file-related slash commands +func (p *Plugin) registerFileCommands() error { + // Register /claude-files command + if err := p.API.RegisterCommand(&model.Command{ + Trigger: "claude-files", + AutoComplete: true, + AutoCompleteDesc: "Browse project files", + DisplayName: "Browse Files", + Description: "Open file browser for the current Claude session", + }); err != nil { + return err + } + + // Register /claude-new-file command + if err := p.API.RegisterCommand(&model.Command{ + Trigger: "claude-new-file", + AutoComplete: true, + AutoCompleteDesc: "Create a new file", + AutoCompleteHint: "[file-path]", + DisplayName: "Create New File", + Description: "Create a new file in the project", + }); err != nil { + return err + } + + return nil +} + +// executeClaudeFiles handles the /claude-files command +func (p *Plugin) executeClaudeFiles(args *model.CommandArgs) *model.CommandResponse { + // Get active session + session, err := p.GetActiveSession(args.ChannelId) + if err != nil { + p.API.LogError("Failed to get active session", "error", err.Error()) + return respondEphemeral("❌ Error retrieving session. Please try again.") + } + + if session == nil { + return respondEphemeral("No active session. Use `/claude-start [project-path]` to begin.") + } + + // Show file browser via interactive message + if err := p.showFileBrowser(args.ChannelId, args.UserId, session.SessionID); err != nil { + p.API.LogError("Failed to show file browser", "error", err.Error()) + return respondEphemeral(fmt.Sprintf("❌ Failed to open file browser: %s", err.Error())) + } + + return &model.CommandResponse{} +} + +// executeClaudeNewFile handles the /claude-new-file command +func (p *Plugin) executeClaudeNewFile(args *model.CommandArgs, filePath string) *model.CommandResponse { + // Get active session + session, err := p.GetActiveSession(args.ChannelId) + if err != nil { + p.API.LogError("Failed to get active session", "error", err.Error()) + return respondEphemeral("❌ Error retrieving session. Please try again.") + } + + if session == nil { + return respondEphemeral("No active session. Use `/claude-start [project-path]` to begin.") + } + + // If path provided, create file directly; otherwise show dialog + if filePath != "" { + return p.createFileDirectly(args.ChannelId, session.SessionID, filePath) + } + + // TODO: Show create file dialog (requires trigger_id from interactive action) + return respondEphemeral("Please provide a file path. Usage: `/claude-new-file `\n\nExample: `/claude-new-file src/components/NewComponent.tsx`") +} + +// showFileBrowser displays an interactive file browser +func (p *Plugin) showFileBrowser(channelID, userID, sessionID string) error { + // Fetch file list from bridge server + files, err := p.bridgeClient.ListFiles(sessionID) + if err != nil { + return fmt.Errorf("failed to list files: %w", err) + } + + // Build file tree representation (flattened for display) + fileList := flattenFileTree(files, "") + + if len(fileList) == 0 { + p.postBotMessage(channelID, "📂 No files found in project") + return nil + } + + // Create interactive message with file actions + message := "📂 **Project Files**\n\nClick a file to view options:" + + var actions []*model.PostAction + for i, file := range fileList { + if i >= 20 { // Limit to 20 files to avoid overwhelming the UI + break + } + + icon := "📄" + if file.Type == "directory" { + icon = "📁" + } + + actions = append(actions, &model.PostAction{ + Name: fmt.Sprintf("%s %s", icon, file.Path), + Integration: &model.PostActionIntegration{ + URL: fmt.Sprintf("%s/api/file-action", p.getPluginURL()), + Context: map[string]interface{}{ + "session_id": sessionID, + "file_path": file.Path, + "file_type": file.Type, + }, + }, + }) + } + + attachment := &model.SlackAttachment{ + Title: "Project Files", + Text: message, + Actions: actions, + } + + post := &model.Post{ + ChannelId: channelID, + UserId: p.botUserID, + Props: model.StringInterface{ + "attachments": []*model.SlackAttachment{attachment}, + }, + } + + if _, err := p.API.CreatePost(post); err != nil { + return fmt.Errorf("failed to create post: %w", err) + } + + return nil +} + +// flattenFileTree converts nested file tree to flat list for display +func flattenFileTree(nodes []FileNode, prefix string) []FileNode { + var result []FileNode + for _, node := range nodes { + displayPath := node.Path + if prefix != "" { + displayPath = filepath.Join(prefix, node.Name) + } + + flatNode := FileNode{ + Name: node.Name, + Path: displayPath, + Type: node.Type, + Size: node.Size, + } + result = append(result, flatNode) + + if node.Type == "directory" && len(node.Children) > 0 { + result = append(result, flattenFileTree(node.Children, displayPath)...) + } + } + return result +} + +// handleFileAction processes file action button clicks +func (p *Plugin) handleFileAction(w http.ResponseWriter, r *http.Request) { + var request struct { + Context map[string]interface{} `json:"context"` + UserID string `json:"user_id"` + ChannelID string `json:"channel_id"` + } + + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + p.API.LogError("Failed to decode file action request", "error", err.Error()) + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + sessionID := request.Context["session_id"].(string) + filePath := request.Context["file_path"].(string) + fileType := request.Context["file_type"].(string) + + // Handle directory vs file + if fileType == "directory" { + // TODO: Navigate into directory + p.postBotMessage(request.ChannelID, fmt.Sprintf("📁 Directory navigation not yet implemented for: `%s`", filePath)) + w.WriteHeader(http.StatusOK) + return + } + + // Show file action menu + if err := p.showFileActionMenu(request.ChannelID, sessionID, filePath); err != nil { + p.API.LogError("Failed to show file action menu", "error", err.Error()) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +// showFileActionMenu displays available actions for a file +func (p *Plugin) showFileActionMenu(channelID, sessionID, filePath string) error { + message := fmt.Sprintf("**File:** `%s`\n\nChoose an action:", filePath) + + actions := []*model.PostAction{ + { + Name: "👁️ View", + Integration: &model.PostActionIntegration{ + URL: fmt.Sprintf("%s/api/file-view", p.getPluginURL()), + Context: map[string]interface{}{ + "session_id": sessionID, + "file_path": filePath, + }, + }, + }, + { + Name: "✏️ Edit", + Integration: &model.PostActionIntegration{ + URL: fmt.Sprintf("%s/api/file-edit", p.getPluginURL()), + Context: map[string]interface{}{ + "session_id": sessionID, + "file_path": filePath, + }, + }, + }, + { + Name: "🗑️ Delete", + Integration: &model.PostActionIntegration{ + URL: fmt.Sprintf("%s/api/file-delete", p.getPluginURL()), + Context: map[string]interface{}{ + "session_id": sessionID, + "file_path": filePath, + }, + }, + Style: "danger", + }, + } + + attachment := &model.SlackAttachment{ + Title: "File Actions", + Text: message, + Actions: actions, + } + + post := &model.Post{ + ChannelId: channelID, + UserId: p.botUserID, + Props: model.StringInterface{ + "attachments": []*model.SlackAttachment{attachment}, + }, + } + + if _, err := p.API.CreatePost(post); err != nil { + return fmt.Errorf("failed to create post: %w", err) + } + + return nil +} + +// handleFileView displays file content +func (p *Plugin) handleFileView(w http.ResponseWriter, r *http.Request) { + var request struct { + Context map[string]interface{} `json:"context"` + UserID string `json:"user_id"` + ChannelID string `json:"channel_id"` + } + + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + p.API.LogError("Failed to decode file view request", "error", err.Error()) + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + sessionID := request.Context["session_id"].(string) + filePath := request.Context["file_path"].(string) + + if err := p.viewFileContent(request.ChannelID, sessionID, filePath); err != nil { + p.API.LogError("Failed to view file", "error", err.Error()) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +// viewFileContent fetches and displays file content +func (p *Plugin) viewFileContent(channelID, sessionID, filePath string) error { + content, err := p.bridgeClient.GetFileContent(sessionID, filePath) + if err != nil { + return fmt.Errorf("failed to get file content: %w", err) + } + + // Detect language for syntax highlighting + ext := filepath.Ext(filePath) + lang := getLanguageFromExtension(ext) + + // Truncate if too long (Mattermost message limit) + const maxLength = 3500 + displayContent := content + truncated := false + if len(content) > maxLength { + displayContent = content[:maxLength] + truncated = true + } + + codeBlock := fmt.Sprintf("```%s\n%s\n```", lang, displayContent) + if truncated { + codeBlock += "\n\n_...content truncated (file too large)_" + } + + message := fmt.Sprintf("📄 **File:** `%s`\n\n%s", filePath, codeBlock) + + post := &model.Post{ + ChannelId: channelID, + UserId: p.botUserID, + Message: message, + } + + if _, err := p.API.CreatePost(post); err != nil { + return fmt.Errorf("failed to create post: %w", err) + } + + return nil +} + +// handleFileEdit initiates file editing +func (p *Plugin) handleFileEdit(w http.ResponseWriter, r *http.Request) { + var request struct { + Context map[string]interface{} `json:"context"` + UserID string `json:"user_id"` + ChannelID string `json:"channel_id"` + } + + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + p.API.LogError("Failed to decode file edit request", "error", err.Error()) + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + filePath := request.Context["file_path"].(string) + + // For now, tell user to edit via Claude or manually + // TODO: Implement interactive dialog editing in Issue #5 + message := fmt.Sprintf("✏️ **Edit:** `%s`\n\nTo edit this file:\n1. Use `/claude` to ask Claude to make changes\n2. Or edit locally and changes will sync automatically", filePath) + + p.postBotMessage(request.ChannelID, message) + w.WriteHeader(http.StatusOK) +} + +// handleFileDelete processes file deletion +func (p *Plugin) handleFileDelete(w http.ResponseWriter, r *http.Request) { + var request struct { + Context map[string]interface{} `json:"context"` + UserID string `json:"user_id"` + ChannelID string `json:"channel_id"` + } + + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + p.API.LogError("Failed to decode file delete request", "error", err.Error()) + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + sessionID := request.Context["session_id"].(string) + filePath := request.Context["file_path"].(string) + + if err := p.deleteFile(request.ChannelID, sessionID, filePath); err != nil { + p.API.LogError("Failed to delete file", "error", err.Error()) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +// deleteFile removes a file from the project +func (p *Plugin) deleteFile(channelID, sessionID, filePath string) error { + if err := p.bridgeClient.DeleteFile(sessionID, filePath); err != nil { + return fmt.Errorf("failed to delete file: %w", err) + } + + message := fmt.Sprintf("🗑️ Deleted file: `%s`", filePath) + p.postBotMessage(channelID, message) + + return nil +} + +// createFileDirectly creates a new file with optional content +func (p *Plugin) createFileDirectly(channelID, sessionID, filePath string) *model.CommandResponse { + if err := p.bridgeClient.CreateFile(sessionID, filePath, ""); err != nil { + p.API.LogError("Failed to create file", "error", err.Error()) + return respondEphemeral(fmt.Sprintf("❌ Failed to create file: %s", err.Error())) + } + + message := fmt.Sprintf("✅ Created file: `%s`\n\nYou can now edit it with `/claude` or your local editor", filePath) + p.postBotMessage(channelID, message) + + return &model.CommandResponse{} +} + +// getLanguageFromExtension maps file extensions to syntax highlighting languages +func getLanguageFromExtension(ext string) string { + langMap := map[string]string{ + ".go": "go", + ".js": "javascript", + ".ts": "typescript", + ".jsx": "jsx", + ".tsx": "tsx", + ".py": "python", + ".rb": "ruby", + ".java": "java", + ".c": "c", + ".cpp": "cpp", + ".cs": "csharp", + ".php": "php", + ".rs": "rust", + ".swift": "swift", + ".kt": "kotlin", + ".scala": "scala", + ".sh": "bash", + ".yaml": "yaml", + ".yml": "yaml", + ".json": "json", + ".xml": "xml", + ".html": "html", + ".css": "css", + ".scss": "scss", + ".md": "markdown", + ".sql": "sql", + } + + if lang, ok := langMap[strings.ToLower(ext)]; ok { + return lang + } + return "" +} + +// registerFileHTTPHandlers registers HTTP handlers for file operations +func (p *Plugin) registerFileHTTPHandlers() { + // These will be called from ServeHTTP + // Routes: + // - POST /api/file-action + // - POST /api/file-view + // - POST /api/file-edit + // - POST /api/file-delete +} diff --git a/server/plugin.go b/server/plugin.go index 7a6a945..0bf7610 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "net/http" "sync" "github.com/mattermost/mattermost/server/public/model" @@ -121,6 +122,31 @@ func (p *Plugin) OnDeactivate() error { return nil } +// ServeHTTP handles HTTP requests to the plugin +func (p *Plugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch r.URL.Path { + case "/api/file-action": + p.handleFileAction(w, r) + case "/api/file-view": + p.handleFileView(w, r) + case "/api/file-edit": + p.handleFileEdit(w, r) + case "/api/file-delete": + p.handleFileDelete(w, r) + default: + w.WriteHeader(404) + _, _ = w.Write([]byte(`{"error": "Not found"}`)) + } +} + +// getPluginURL returns the base URL for plugin HTTP endpoints +func (p *Plugin) getPluginURL() string { + siteURL := *p.API.GetConfig().ServiceSettings.SiteURL + return fmt.Sprintf("%s/plugins/com.appsome.claudecode", siteURL) +} + func main() { plugin.ClientMain(&Plugin{}) } From 65a5a9ff17de6690c2d01cef9992fb48814ca065 Mon Sep 17 00:00:00 2001 From: Ada Date: Mon, 9 Mar 2026 21:23:38 +0000 Subject: [PATCH 2/3] fix: resolve rebase conflicts after syncing with main - Remove duplicate ServeHTTP handler from plugin.go - Remove duplicate getPluginURL implementation - Keep unified handlers from actions.go/post_utils.go Verified with make test (backend + frontend passing). --- server/plugin.go | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/server/plugin.go b/server/plugin.go index 0bf7610..7a6a945 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -2,7 +2,6 @@ package main import ( "fmt" - "net/http" "sync" "github.com/mattermost/mattermost/server/public/model" @@ -122,31 +121,6 @@ func (p *Plugin) OnDeactivate() error { return nil } -// ServeHTTP handles HTTP requests to the plugin -func (p *Plugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - switch r.URL.Path { - case "/api/file-action": - p.handleFileAction(w, r) - case "/api/file-view": - p.handleFileView(w, r) - case "/api/file-edit": - p.handleFileEdit(w, r) - case "/api/file-delete": - p.handleFileDelete(w, r) - default: - w.WriteHeader(404) - _, _ = w.Write([]byte(`{"error": "Not found"}`)) - } -} - -// getPluginURL returns the base URL for plugin HTTP endpoints -func (p *Plugin) getPluginURL() string { - siteURL := *p.API.GetConfig().ServiceSettings.SiteURL - return fmt.Sprintf("%s/plugins/com.appsome.claudecode", siteURL) -} - func main() { plugin.ClientMain(&Plugin{}) } From e13aadab5bf2e9054f86340b64e567952c6bb0dd Mon Sep 17 00:00:00 2001 From: Ada Date: Mon, 9 Mar 2026 21:30:43 +0000 Subject: [PATCH 3/3] fix: Apply gofmt to fix linting issues --- server/commands.go | 14 +++--- server/file_operations.go | 94 +++++++++++++++++++-------------------- 2 files changed, 54 insertions(+), 54 deletions(-) diff --git a/server/commands.go b/server/commands.go index 5816094..a46f855 100644 --- a/server/commands.go +++ b/server/commands.go @@ -10,14 +10,14 @@ import ( ) const ( - commandTriggerClaude = "claude" - commandTriggerClaudeStart = "claude-start" - commandTriggerClaudeStop = "claude-stop" - commandTriggerClaudeStatus = "claude-status" - commandTriggerClaudeThread = "claude-thread" - commandTriggerClaudeFiles = "claude-files" + commandTriggerClaude = "claude" + commandTriggerClaudeStart = "claude-start" + commandTriggerClaudeStop = "claude-stop" + commandTriggerClaudeStatus = "claude-status" + commandTriggerClaudeThread = "claude-thread" + commandTriggerClaudeFiles = "claude-files" commandTriggerClaudeNewFile = "claude-new-file" - commandTriggerClaudeHelp = "claude-help" + commandTriggerClaudeHelp = "claude-help" ) func (p *Plugin) registerCommands() error { diff --git a/server/file_operations.go b/server/file_operations.go index cef3df6..31ce6a5 100644 --- a/server/file_operations.go +++ b/server/file_operations.go @@ -12,11 +12,11 @@ import ( // FileNode represents a file or directory in the file tree type FileNode struct { - Name string `json:"name"` - Path string `json:"path"` - Type string `json:"type"` // "file" or "directory" - Size *int64 `json:"size,omitempty"` - Children []FileNode `json:"children,omitempty"` + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` // "file" or "directory" + Size *int64 `json:"size,omitempty"` + Children []FileNode `json:"children,omitempty"` } // FileActionType represents available file operations @@ -111,7 +111,7 @@ func (p *Plugin) showFileBrowser(channelID, userID, sessionID string) error { // Build file tree representation (flattened for display) fileList := flattenFileTree(files, "") - + if len(fileList) == 0 { p.postBotMessage(channelID, "📂 No files found in project") return nil @@ -119,18 +119,18 @@ func (p *Plugin) showFileBrowser(channelID, userID, sessionID string) error { // Create interactive message with file actions message := "📂 **Project Files**\n\nClick a file to view options:" - + var actions []*model.PostAction for i, file := range fileList { if i >= 20 { // Limit to 20 files to avoid overwhelming the UI break } - + icon := "📄" if file.Type == "directory" { icon = "📁" } - + actions = append(actions, &model.PostAction{ Name: fmt.Sprintf("%s %s", icon, file.Path), Integration: &model.PostActionIntegration{ @@ -173,7 +173,7 @@ func flattenFileTree(nodes []FileNode, prefix string) []FileNode { if prefix != "" { displayPath = filepath.Join(prefix, node.Name) } - + flatNode := FileNode{ Name: node.Name, Path: displayPath, @@ -192,9 +192,9 @@ func flattenFileTree(nodes []FileNode, prefix string) []FileNode { // handleFileAction processes file action button clicks func (p *Plugin) handleFileAction(w http.ResponseWriter, r *http.Request) { var request struct { - Context map[string]interface{} `json:"context"` - UserID string `json:"user_id"` - ChannelID string `json:"channel_id"` + Context map[string]interface{} `json:"context"` + UserID string `json:"user_id"` + ChannelID string `json:"channel_id"` } if err := json.NewDecoder(r.Body).Decode(&request); err != nil { @@ -287,9 +287,9 @@ func (p *Plugin) showFileActionMenu(channelID, sessionID, filePath string) error // handleFileView displays file content func (p *Plugin) handleFileView(w http.ResponseWriter, r *http.Request) { var request struct { - Context map[string]interface{} `json:"context"` - UserID string `json:"user_id"` - ChannelID string `json:"channel_id"` + Context map[string]interface{} `json:"context"` + UserID string `json:"user_id"` + ChannelID string `json:"channel_id"` } if err := json.NewDecoder(r.Body).Decode(&request); err != nil { @@ -353,9 +353,9 @@ func (p *Plugin) viewFileContent(channelID, sessionID, filePath string) error { // handleFileEdit initiates file editing func (p *Plugin) handleFileEdit(w http.ResponseWriter, r *http.Request) { var request struct { - Context map[string]interface{} `json:"context"` - UserID string `json:"user_id"` - ChannelID string `json:"channel_id"` + Context map[string]interface{} `json:"context"` + UserID string `json:"user_id"` + ChannelID string `json:"channel_id"` } if err := json.NewDecoder(r.Body).Decode(&request); err != nil { @@ -369,7 +369,7 @@ func (p *Plugin) handleFileEdit(w http.ResponseWriter, r *http.Request) { // For now, tell user to edit via Claude or manually // TODO: Implement interactive dialog editing in Issue #5 message := fmt.Sprintf("✏️ **Edit:** `%s`\n\nTo edit this file:\n1. Use `/claude` to ask Claude to make changes\n2. Or edit locally and changes will sync automatically", filePath) - + p.postBotMessage(request.ChannelID, message) w.WriteHeader(http.StatusOK) } @@ -377,9 +377,9 @@ func (p *Plugin) handleFileEdit(w http.ResponseWriter, r *http.Request) { // handleFileDelete processes file deletion func (p *Plugin) handleFileDelete(w http.ResponseWriter, r *http.Request) { var request struct { - Context map[string]interface{} `json:"context"` - UserID string `json:"user_id"` - ChannelID string `json:"channel_id"` + Context map[string]interface{} `json:"context"` + UserID string `json:"user_id"` + ChannelID string `json:"channel_id"` } if err := json.NewDecoder(r.Body).Decode(&request); err != nil { @@ -428,32 +428,32 @@ func (p *Plugin) createFileDirectly(channelID, sessionID, filePath string) *mode // getLanguageFromExtension maps file extensions to syntax highlighting languages func getLanguageFromExtension(ext string) string { langMap := map[string]string{ - ".go": "go", - ".js": "javascript", - ".ts": "typescript", - ".jsx": "jsx", - ".tsx": "tsx", - ".py": "python", - ".rb": "ruby", - ".java": "java", - ".c": "c", - ".cpp": "cpp", - ".cs": "csharp", - ".php": "php", - ".rs": "rust", + ".go": "go", + ".js": "javascript", + ".ts": "typescript", + ".jsx": "jsx", + ".tsx": "tsx", + ".py": "python", + ".rb": "ruby", + ".java": "java", + ".c": "c", + ".cpp": "cpp", + ".cs": "csharp", + ".php": "php", + ".rs": "rust", ".swift": "swift", - ".kt": "kotlin", + ".kt": "kotlin", ".scala": "scala", - ".sh": "bash", - ".yaml": "yaml", - ".yml": "yaml", - ".json": "json", - ".xml": "xml", - ".html": "html", - ".css": "css", - ".scss": "scss", - ".md": "markdown", - ".sql": "sql", + ".sh": "bash", + ".yaml": "yaml", + ".yml": "yaml", + ".json": "json", + ".xml": "xml", + ".html": "html", + ".css": "css", + ".scss": "scss", + ".md": "markdown", + ".sql": "sql", } if lang, ok := langMap[strings.ToLower(ext)]; ok {