Skip to content

File Explorer & File Operations #6

@ada-evorada

Description

@ada-evorada

Issue #6: File Explorer & File Operations

Epic: #1
Phase: 3 - Interactive Features
Estimated Time: 4-5 days
Priority: High
Depends On: #3, #5

Goal

Enable users to browse, view, edit, create, and delete project files directly from Mattermost using interactive dialogs and file operations.

Tasks

1. File Tree Dialog

Slash Command: /claude-files

Show file browser dialog:

func (p *Plugin) showFileBrowser(triggerID, sessionID string) error {
    // Get file tree from bridge server
    files, err := p.bridgeClient.ListFiles(sessionID)
    if err != nil {
        return err
    }
    
    // Build file tree options
    options := make([]model.DialogElement, 0)
    for _, file := range files {
        options = append(options, model.DialogElement{
            DisplayName: file.Path,
            Name:        "file",
            Type:        "select",
            DataSource:  "files",
        })
    }
    
    dialog := model.OpenDialogRequest{
        TriggerId: triggerID,
        URL:       p.getPluginURL() + "/api/dialog/file-action",
        Dialog: model.Dialog{
            Title:    "Project Files",
            Elements: options,
            SubmitLabel: "Open",
        },
    }
    
    return p.API.OpenInteractiveDialog(dialog)
}

2. File Actions Menu

After selecting a file, show actions:

type FileAction string

const (
    FileActionView   FileAction = "view"
    FileActionEdit   FileAction = "edit"
    FileActionDelete FileAction = "delete"
    FileActionDiff   FileAction = "diff"
)

func (p *Plugin) showFileActions(triggerID, sessionID, filePath string) {
    dialog := model.OpenDialogRequest{
        TriggerId: triggerID,
        URL:       p.getPluginURL() + "/api/dialog/file-operation",
        Dialog: model.Dialog{
            Title: filepath.Base(filePath),
            Elements: []model.DialogElement{
                {
                    DisplayName: "Action",
                    Name:        "action",
                    Type:        "radio",
                    Options: []*model.PostActionOptions{
                        {Text: "👁️ View", Value: string(FileActionView)},
                        {Text: "✏️ Edit", Value: string(FileActionEdit)},
                        {Text: "🔍 Show Diff", Value: string(FileActionDiff)},
                        {Text: "🗑️ Delete", Value: string(FileActionDelete)},
                    },
                },
            },
            SubmitLabel: "Execute",
        },
    }
    
    p.API.OpenInteractiveDialog(dialog)
}

3. View File Content

Display file in code block:

func (p *Plugin) viewFile(channelID, sessionID, filePath string) error {
    content, err := p.bridgeClient.GetFile(sessionID, filePath)
    if err != nil {
        return err
    }
    
    // Detect language for syntax highlighting
    ext := filepath.Ext(filePath)
    lang := getLanguageFromExtension(ext)
    
    codeBlock := fmt.Sprintf("```%s\n%s\n```", lang, content)
    
    attachment := &model.SlackAttachment{
        Title: filePath,
        Text:  codeBlock,
        Actions: []*model.PostAction{
            {Name: "✏️ Edit", ...},
            {Name: "📋 Copy", ...},
        },
    }
    
    post := &model.Post{
        ChannelId: channelID,
        UserId:    p.botUserID,
        Message:   "**File:** " + filePath,
        Props: model.StringInterface{
            "attachments": []*model.SlackAttachment{attachment},
        },
    }
    
    _, err = p.API.CreatePost(post)
    return err
}

4. Edit File Dialog

Open text editor dialog:

func (p *Plugin) showFileEditor(triggerID, sessionID, filePath string) error {
    content, err := p.bridgeClient.GetFile(sessionID, filePath)
    if err != nil {
        return err
    }
    
    dialog := model.OpenDialogRequest{
        TriggerId: triggerID,
        URL:       p.getPluginURL() + "/api/dialog/save-file",
        Dialog: model.Dialog{
            Title: "Edit: " + filepath.Base(filePath),
            IntroductionText: "Editing " + filePath,
            Elements: []model.DialogElement{
                {
                    DisplayName: "Content",
                    Name:        "content",
                    Type:        "textarea",
                    Default:     content,
                    MaxLength:   10000, // Mattermost limit
                },
            },
            SubmitLabel: "Save",
        },
    }
    
    return p.API.OpenInteractiveDialog(dialog)
}

Handle Save:

func (p *Plugin) handleSaveFile(w http.ResponseWriter, r *http.Request) {
    submission := model.SubmitDialogRequestFromJson(r.Body)
    
    filePath := submission.State // Stored from previous dialog
    content := submission.Submission["content"].(string)
    
    err := p.bridgeClient.UpdateFile(submission.ChannelId, filePath, content)
    if err != nil {
        response := &model.SubmitDialogResponse{
            Error: "Failed to save file: " + err.Error(),
        }
        json.NewEncoder(w).Encode(response)
        return
    }
    
    // Post confirmation
    p.postBotMessage(submission.ChannelId, "✅ Saved changes to "+filePath)
    
    response := &model.SubmitDialogResponse{}
    json.NewEncoder(w).Encode(response)
}

5. Create New File

Slash Command: /claude-new-file

func (p *Plugin) showCreateFileDialog(triggerID, sessionID string) {
    dialog := model.OpenDialogRequest{
        TriggerId: triggerID,
        URL:       p.getPluginURL() + "/api/dialog/create-file",
        Dialog: model.Dialog{
            Title: "Create New File",
            Elements: []model.DialogElement{
                {
                    DisplayName: "File Path",
                    Name:        "path",
                    Type:        "text",
                    Placeholder: "src/components/MyComponent.tsx",
                    HelpText:    "Relative to project root",
                },
                {
                    DisplayName: "Content",
                    Name:        "content",
                    Type:        "textarea",
                    Optional:    true,
                },
            },
            SubmitLabel: "Create",
        },
    }
    
    p.API.OpenInteractiveDialog(dialog)
}

6. Show Diff

Display file changes:

func (p *Plugin) showFileDiff(channelID, sessionID, filePath string) error {
    diff, err := p.bridgeClient.GetFileDiff(sessionID, filePath)
    if err != nil {
        return err
    }
    
    diffBlock := "```diff\n" + diff + "\n```"
    
    attachment := &model.SlackAttachment{
        Title: "Changes: " + filePath,
        Text:  diffBlock,
        Color: "#FFAA00",
        Actions: []*model.PostAction{
            {Name: "✅ Apply", ...},
            {Name: "❌ Discard", ...},
            {Name: "💬 Discuss", ...},
        },
    }
    
    post := &model.Post{
        ChannelId: channelID,
        UserId:    p.botUserID,
        Message:   "**Uncommitted Changes**",
        Props: model.StringInterface{
            "attachments": []*model.SlackAttachment{attachment},
        },
    }
    
    _, err = p.API.CreatePost(post)
    return err
}

7. Delete File (with confirmation)

func (p *Plugin) confirmDeleteFile(triggerID, filePath string) {
    dialog := model.OpenDialogRequest{
        TriggerId: triggerID,
        URL:       p.getPluginURL() + "/api/dialog/confirm-delete",
        Dialog: model.Dialog{
            Title:            "Delete File",
            IntroductionText: fmt.Sprintf("⚠️ Are you sure you want to delete `%s`?\n\nThis cannot be undone.", filePath),
            SubmitLabel:      "Delete",
            NotifyOnCancel:   false,
        },
    }
    
    p.API.OpenInteractiveDialog(dialog)
}

8. File Tree Component (React)

For future webapp enhancement:

// webapp/src/components/FileTree.tsx
interface FileNode {
  name: string;
  path: string;
  type: 'file' | 'directory';
  children?: FileNode[];
}

export const FileTree: React.FC<{ sessionId: string }> = ({ sessionId }) => {
  const [files, setFiles] = useState<FileNode[]>([]);
  
  useEffect(() => {
    fetchFiles(sessionId).then(setFiles);
  }, [sessionId]);
  
  return (
    <div className="file-tree">
      {files.map(node => (
        <FileTreeNode key={node.path} node={node} onSelect={handleFileSelect} />
      ))}
    </div>
  );
};

Bridge Server Endpoints

// GET /api/sessions/:id/files
router.get('/:id/files', async (req, res) => {
  const files = await fileManager.listFiles(req.params.id);
  res.json({ files });
});

// GET /api/sessions/:id/files/:path
router.get('/:id/files/:path', async (req, res) => {
  const content = await fileManager.readFile(req.params.id, req.params.path);
  res.json({ content });
});

// PUT /api/sessions/:id/files/:path
router.put('/:id/files/:path', async (req, res) => {
  await fileManager.writeFile(req.params.id, req.params.path, req.body.content);
  res.json({ success: true });
});

// POST /api/sessions/:id/files
router.post('/:id/files', async (req, res) => {
  await fileManager.createFile(req.params.id, req.body.path, req.body.content);
  res.json({ success: true });
});

// DELETE /api/sessions/:id/files/:path
router.delete('/:id/files/:path', async (req, res) => {
  await fileManager.deleteFile(req.params.id, req.params.path);
  res.json({ success: true });
});

File Structure

server/
├── file_operations.go      # File operation handlers
├── file_browser.go         # File browser dialog
└── types.go                # File-related types

bridge-server/src/
├── file-manager.ts         # File system operations
└── api/files.ts           # File API endpoints

Security Considerations

  • Validate file paths (prevent directory traversal)
  • Limit file sizes (respect Mattermost limits)
  • Check file permissions
  • Sanitize file content before display
  • Restrict operations to project directory
func validateFilePath(projectPath, requestedPath string) error {
    absPath := filepath.Join(projectPath, requestedPath)
    
    // Prevent directory traversal
    if !strings.HasPrefix(absPath, projectPath) {
        return errors.New("invalid file path: outside project directory")
    }
    
    // Check file exists
    if _, err := os.Stat(absPath); os.IsNotExist(err) {
        return errors.New("file does not exist")
    }
    
    return nil
}

Testing

Unit Tests

  • File path validation
  • File content retrieval
  • Dialog creation
  • Save operations

Integration Tests

  • Browse files → select → view content
  • Edit file → save → verify changes
  • Create new file → verify exists
  • Delete file → verify removed
  • Show diff → verify accuracy

Acceptance Criteria

  • Can browse project files via /claude-files
  • Can view file content in code blocks
  • Can edit files via dialog (up to 10KB)
  • Can create new files with initial content
  • Can delete files with confirmation
  • Diff view shows uncommitted changes
  • All file paths validated for security
  • Mobile-friendly dialogs
  • Syntax highlighting works for common languages
  • Error handling for permission issues

Related Issues

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions