Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 54 additions & 2 deletions internal/plugins/tdmonitor/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/marcus/sidecar/internal/plugin"
"github.com/marcus/sidecar/internal/plugins/workspace"
"github.com/marcus/sidecar/internal/styles"
"github.com/marcus/sidecar/internal/tdroot"
)

const (
Expand All @@ -37,6 +38,9 @@ type Plugin struct {
// Setup modal (shown when td is on PATH but project not initialized)
setupModal *SetupModel

// todosConflict is set when .todos exists as a file instead of a directory
todosConflict bool

// tdOnPath tracks whether td binary is available on the system
tdOnPath bool

Expand Down Expand Up @@ -73,8 +77,18 @@ func (p *Plugin) Init(ctx *plugin.Context) error {
p.model = nil
p.notInstalled = nil
p.setupModal = nil
p.todosConflict = false
p.started = false

// Check if .todos exists as a file instead of a directory (#194).
// This must happen before attempting to create the monitor or showing
// the setup modal, since td init will fail in this state.
if err := tdroot.CheckTodosConflict(ctx.WorkDir); err != nil {
p.ctx.Logger.Warn("td monitor: .todos path conflict", "error", err)
p.todosConflict = true
return nil
}

// Check if td binary is available on PATH
_, err := exec.LookPath("td")
p.tdOnPath = err == nil
Expand Down Expand Up @@ -155,6 +169,17 @@ func (p *Plugin) Update(msg tea.Msg) (plugin.Plugin, tea.Cmd) {
return p, nil
}

// Handle setup error - show error toast
if errMsg, ok := msg.(SetupErrorMsg); ok {
return p, func() tea.Msg {
return app.ToastMsg{
Message: errMsg.Error,
Duration: 5 * time.Second,
IsError: true,
}
}
}

// Handle setup skip - show not-installed view
if _, ok := msg.(SetupSkippedMsg); ok {
p.setupModal = nil
Expand Down Expand Up @@ -287,7 +312,9 @@ func (p *Plugin) View(width, height int) string {
p.height = height

var content string
if p.model == nil {
if p.todosConflict {
content = renderConflictView(width)
} else if p.model == nil {
if p.setupModal != nil {
content = p.setupModal.View(width, height)
} else if p.notInstalled != nil {
Expand Down Expand Up @@ -388,7 +415,10 @@ func (p *Plugin) Diagnostics() []plugin.Diagnostic {
status := "ok"
detail := ""

if p.model == nil {
if p.todosConflict {
status = "error"
detail = ".todos is a file, not a directory"
} else if p.model == nil {
status = "disabled"
detail = "no database"
} else {
Expand Down Expand Up @@ -417,6 +447,28 @@ func formatCount(n int, singular, plural string) string {
return fmt.Sprintf("%d %s", n, plural)
}

// renderConflictView renders the error view when .todos is a file instead of a directory.
func renderConflictView(width int) string {
theme := styles.GetCurrentTheme()

title := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(theme.Colors.Error)).
Render("Cannot initialize td")

body := lipgloss.NewStyle().
Width(width - 4).
Render(
"Found a .todos file where a directory is expected.\n" +
"This may have been created by another tool or AI agent.\n\n" +
"To fix, remove or rename the file:\n\n" +
" mv .todos .todos.bak\n" +
" td init\n\n" +
"Then restart sidecar.")

return lipgloss.JoinVertical(lipgloss.Left, "", title, "", body)
}

// buildMarkdownTheme creates a MarkdownThemeConfig from the current sidecar theme.
// This shares sidecar's color palette with td's markdown renderer.
func buildMarkdownTheme() *monitor.MarkdownThemeConfig {
Expand Down
108 changes: 82 additions & 26 deletions internal/plugins/tdmonitor/plugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ package tdmonitor
import (
"log/slog"
"os"
"path/filepath"
"strings"
"testing"

"github.com/marcus/sidecar/internal/plugin"
"github.com/marcus/sidecar/internal/tdroot"
)

func TestNew(t *testing.T) {
Expand Down Expand Up @@ -102,34 +104,47 @@ func TestInitWithNonExistentDatabase(t *testing.T) {
}
}

func TestInitWithValidDatabase(t *testing.T) {
// Find project root by walking up to find .todos
// findProjectRootWithDB walks up from cwd to find a directory whose resolved
// td database path (following .td-root) actually exists. Returns the clean
// project root or calls t.Skip if no usable database is found.
func findProjectRootWithDB(t *testing.T) string {
t.Helper()
cwd, err := os.Getwd()
if err != nil {
t.Skip("couldn't get working directory")
}

// The test runs from internal/plugins/tdmonitor, so go up to project root
// Walk up to find a directory with .todos/issues.db
projectRoot := cwd
for i := 0; i < 5; i++ {
if _, err := os.Stat(projectRoot + "/.todos/issues.db"); err == nil {
break
}
projectRoot = projectRoot + "/.."
}

// Verify we found a .todos directory
if _, err := os.Stat(projectRoot + "/.todos/issues.db"); err != nil {
t.Skip("no .todos database found in project hierarchy")
projectRoot = filepath.Clean(projectRoot)

// Verify the *resolved* database path exists. The monitor follows .td-root
// which may redirect to a different directory (e.g., a worktree root on
// another machine). Skip if the resolved path is unreachable.
resolvedDBPath := tdroot.ResolveDBPath(projectRoot)
if _, err := os.Stat(resolvedDBPath); err != nil {
t.Skipf("resolved td database not accessible: %s", resolvedDBPath)
}

return projectRoot
}

func TestInitWithValidDatabase(t *testing.T) {
projectRoot := findProjectRootWithDB(t)

p := New()
ctx := &plugin.Context{
WorkDir: projectRoot,
Logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})),
}

err = p.Init(ctx)
err := p.Init(ctx)
if err != nil {
t.Errorf("Init failed: %v", err)
}
Expand All @@ -144,24 +159,7 @@ func TestInitWithValidDatabase(t *testing.T) {
}

func TestDiagnosticsWithDatabase(t *testing.T) {
// Find project root by walking up to find .todos
cwd, err := os.Getwd()
if err != nil {
t.Skip("couldn't get working directory")
}

projectRoot := cwd
for i := 0; i < 5; i++ {
if _, err := os.Stat(projectRoot + "/.todos/issues.db"); err == nil {
break
}
projectRoot = projectRoot + "/.."
}

// Verify we found a .todos directory
if _, err := os.Stat(projectRoot + "/.todos/issues.db"); err != nil {
t.Skip("no .todos database found in project hierarchy")
}
projectRoot := findProjectRootWithDB(t)

p := New()
ctx := &plugin.Context{
Expand Down Expand Up @@ -229,3 +227,61 @@ func TestViewWithoutModel(t *testing.T) {
t.Error("expected non-empty view")
}
}

func TestInitWithTodosFileConflict(t *testing.T) {
// Create temp directory with .todos as a regular FILE (not directory)
tmpDir, err := os.MkdirTemp("", "tdmonitor-conflict-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()

todosFile := filepath.Join(tmpDir, ".todos")
if err := os.WriteFile(todosFile, []byte("not a directory"), 0644); err != nil {
t.Fatalf("failed to write .todos file: %v", err)
}

p := New()
ctx := &plugin.Context{
WorkDir: tmpDir,
Logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})),
}

// Init should not return an error (silent degradation)
if err := p.Init(ctx); err != nil {
t.Errorf("Init should not return error, got: %v", err)
}

// Plugin should detect the conflict
if !p.todosConflict {
t.Error("expected todosConflict to be true when .todos is a file")
}

// Model should be nil (no monitor created)
if p.model != nil {
t.Error("model should be nil when .todos is a file")
}

// Setup modal should NOT be shown (the conflict takes priority)
if p.setupModal != nil {
t.Error("setupModal should be nil when .todos is a file")
}

// View should contain the conflict error message
view := p.View(80, 24)
if !strings.Contains(view, "file where a directory is expected") {
t.Errorf("expected conflict error in view, got: %s", view)
}

// Diagnostics should report the error
diags := p.Diagnostics()
if len(diags) != 1 {
t.Fatalf("expected 1 diagnostic, got %d", len(diags))
}
if diags[0].Status != "error" {
t.Errorf("expected diagnostic status 'error', got %q", diags[0].Status)
}
if !strings.Contains(diags[0].Detail, "file, not a directory") {
t.Errorf("expected diagnostic detail about file conflict, got %q", diags[0].Detail)
}
}
20 changes: 18 additions & 2 deletions internal/plugins/tdmonitor/setup_modal.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (
"github.com/marcus/td/pkg/monitor"
"github.com/marcus/td/pkg/monitor/modal"
"github.com/marcus/td/pkg/monitor/mouse"

"github.com/marcus/sidecar/internal/tdroot"
)

// SetupModel handles the setup modal when td is on PATH but not initialized in project.
Expand Down Expand Up @@ -107,16 +109,30 @@ func (m *SetupModel) handleAction(action string) tea.Cmd {
return nil
}

// SetupErrorMsg is sent when setup encounters a blocking error.
type SetupErrorMsg struct {
Error string
}

// performSetup executes the selected setup options.
func (m *SetupModel) performSetup() tea.Cmd {
return func() tea.Msg {
// Check for .todos file conflict before running td init (#194)
if m.initDB {
if err := tdroot.CheckTodosConflict(m.baseDir); err != nil {
return SetupErrorMsg{
Error: "A .todos file exists where a directory is expected. " +
"Remove or rename it (mv .todos .todos.bak) and try again.",
}
}
}

if m.initDB {
// Call td init via exec
cmd := exec.Command("td", "init")
cmd.Dir = m.baseDir
if err := cmd.Run(); err != nil {
// Return nil on error - could add error display
return nil
return SetupErrorMsg{Error: "td init failed: " + err.Error()}
}
}

Expand Down
21 changes: 21 additions & 0 deletions internal/tdroot/tdroot.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package tdroot

import (
"errors"
"os"
"os/exec"
"path/filepath"
Expand Down Expand Up @@ -78,6 +79,26 @@ func ResolveDBPath(workDir string) string {
return filepath.Join(root, TodosDir, DBFile)
}

// ErrTodosIsFile is returned when .todos exists as a file instead of a directory.
var ErrTodosIsFile = errors.New("found .todos file where a directory is expected")

// CheckTodosConflict checks whether a .todos path exists as a regular file
// instead of a directory. This can happen when an AI agent or other tool
// creates a .todos file, conflicting with td's expected .todos directory.
// Returns ErrTodosIsFile if there's a conflict, nil otherwise.
func CheckTodosConflict(workDir string) error {
root := ResolveTDRoot(workDir)
todosPath := filepath.Join(root, TodosDir)
fi, err := os.Lstat(todosPath)
if err != nil {
return nil // doesn't exist — no conflict
}
if !fi.IsDir() {
return ErrTodosIsFile
}
return nil
}

// CreateTDRoot writes a .td-root file pointing to targetRoot.
// Used when creating worktrees to share the td database.
func CreateTDRoot(worktreePath, targetRoot string) error {
Expand Down
Loading