diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index de5f395..1e65e9e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,7 +5,7 @@ on: tags: ["v*"] permissions: - contents: write + contents: read jobs: test: @@ -16,10 +16,10 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: go-version: "1.26.x" - run: go test ./... @@ -29,14 +29,16 @@ jobs: name: Release runs-on: macos-latest needs: test + permissions: + contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: go-version: "1.26.x" - - uses: goreleaser/goreleaser-action@v6 + - uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552 # v6.3.0 with: distribution: goreleaser version: "~> v2" diff --git a/internal/app/root.go b/internal/app/root.go index 8a54ed0..8dc75d2 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -54,46 +54,7 @@ func NewRootCommand() *cobra.Command { return tui.Run(cmd.Context(), cfg) }, PersistentPreRunE: func(_ *cobra.Command, _ []string) error { - loaded, err := config.Load() - if err != nil { - cfgPath, _ := config.Path() - return fmt.Errorf("cannot load config at %q: %w\nhint: ensure %s is writable", cfgPath, err, filepath.Dir(cfgPath)) - } - cfg = loaded - - if opt.DBPath == "" { - if cfg.DBPath != "" { - resolved, resolveErr := config.ExpandPath(cfg.DBPath) - if resolveErr != nil { - return resolveErr - } - opt.DBPath = resolved - } else { - dbPath, dbErr := config.Path() - if dbErr != nil { - return dbErr - } - opt.DBPath = filepath.Join(filepath.Dir(dbPath), "goeverything.db") - } - } - if opt.Batch <= 0 { - opt.Batch = 2000 - } - if opt.Workers <= 0 { - opt.Workers = scanner.DefaultWorkerCount() - } - if len(opt.Exclude) == 0 { - opt.Exclude = cfg.Excludes - if len(opt.Exclude) == 0 { - opt.Exclude = scanner.DefaultExcludes() - } - } - if err := os.MkdirAll(filepath.Dir(opt.DBPath), 0o755); err != nil { - return fmt.Errorf("cannot create db dir %q: %w\nhint: ensure the configured data directory is writable or pass --db", filepath.Dir(opt.DBPath), err) - } - cfg.DBPath = opt.DBPath - cfg.Excludes = opt.Exclude - return nil + return prepareOptions(&opt, &cfg) }, } @@ -109,6 +70,70 @@ func NewRootCommand() *cobra.Command { return cmd } +func prepareOptions(opt *options, cfg *config.Config) error { + if err := loadOptionsConfig(cfg); err != nil { + return err + } + if err := resolveDBPath(opt, *cfg); err != nil { + return err + } + applyOptionDefaults(opt, *cfg) + return ensureDBDirectory(opt, cfg) +} + +func loadOptionsConfig(cfg *config.Config) error { + loaded, err := config.Load() + if err == nil { + *cfg = loaded + return nil + } + cfgPath, _ := config.Path() + return fmt.Errorf("cannot load config at %q: %w\nhint: ensure %s is writable", cfgPath, err, filepath.Dir(cfgPath)) +} + +func resolveDBPath(opt *options, cfg config.Config) error { + if opt.DBPath != "" { + return nil + } + if cfg.DBPath != "" { + resolved, err := config.ExpandPath(cfg.DBPath) + if err != nil { + return err + } + opt.DBPath = resolved + return nil + } + dbPath, err := config.Path() + if err != nil { + return err + } + opt.DBPath = filepath.Join(filepath.Dir(dbPath), "goeverything.db") + return nil +} + +func applyOptionDefaults(opt *options, cfg config.Config) { + if opt.Batch <= 0 { + opt.Batch = 2000 + } + if opt.Workers <= 0 { + opt.Workers = scanner.DefaultWorkerCount() + } + if len(opt.Exclude) == 0 { + opt.Exclude = cfg.Excludes + if len(opt.Exclude) == 0 { + opt.Exclude = scanner.DefaultExcludes() + } + } +} + +func ensureDBDirectory(opt *options, cfg *config.Config) error { + if err := os.MkdirAll(filepath.Dir(opt.DBPath), 0o755); err != nil { + return fmt.Errorf("cannot create db dir %q: %w\nhint: ensure the configured data directory is writable or pass --db", filepath.Dir(opt.DBPath), err) + } + cfg.DBPath, cfg.Excludes = opt.DBPath, opt.Exclude + return nil +} + func newScanCommand(opt *options) *cobra.Command { command := &cobra.Command{ Use: "scan", diff --git a/internal/config/config.go b/internal/config/config.go index 28cc018..fb8cf2a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -45,29 +45,8 @@ func Load() (Config, error) { } cfg := defaults() - data, err := os.ReadFile(path) + data, err := readConfigData(path) if err != nil { - if errors.Is(err, os.ErrNotExist) { - for _, candidate := range legacyPaths() { - if candidate == path { - continue - } - data, err = os.ReadFile(candidate) - if err == nil { - // The legacy file is migrated into the portable location below. - break - } - if !errors.Is(err, os.ErrNotExist) { - return Config{}, err - } - } - } - if errors.Is(err, os.ErrNotExist) { - if saveErr := Save(cfg); saveErr != nil { - return Config{}, saveErr - } - return cfg, nil - } return Config{}, err } if len(data) == 0 { @@ -77,13 +56,11 @@ func Load() (Config, error) { return cfg, nil } - if err := json.Unmarshal(data, &cfg); err != nil { - var old legacyConfig - if err2 := json.Unmarshal(data, &old); err2 != nil { - return Config{}, err - } - cfg = fromLegacy(old) + decoded, err := decodeConfig(data, cfg) + if err != nil { + return Config{}, err } + cfg = decoded cfg.normalize() if err := Save(cfg); err != nil { @@ -92,6 +69,43 @@ func Load() (Config, error) { return cfg, nil } +func readConfigData(path string) ([]byte, error) { + data, err := os.ReadFile(path) + if err == nil || !errors.Is(err, os.ErrNotExist) { + return data, err + } + for _, candidate := range legacyPaths() { + if candidate == path { + continue + } + data, err = os.ReadFile(candidate) + if err == nil { + // The legacy file is migrated into the portable location below. + return data, nil + } + if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + } + return nil, nil +} + +func decodeConfig(data []byte, cfg Config) (Config, error) { + err := json.Unmarshal(data, &cfg) + if err == nil { + return cfg, nil + } + return decodeLegacyConfig(data, err) +} + +func decodeLegacyConfig(data []byte, currentErr error) (Config, error) { + old := legacyConfig{} + if err := json.Unmarshal(data, &old); err != nil { + return Config{}, currentErr + } + return fromLegacy(old), nil +} + func Save(cfg Config) error { cfg.normalize() path, err := Path() diff --git a/internal/db/migrations.go b/internal/db/migrations.go index 2cfed59..02ed752 100644 --- a/internal/db/migrations.go +++ b/internal/db/migrations.go @@ -12,7 +12,18 @@ import ( "time" ) -const migrationsTableName = "goose_db_version" +const ( + createMigrationsTableSQL = `CREATE TABLE IF NOT EXISTS goose_db_version ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + version_id INTEGER NOT NULL, + is_applied INTEGER NOT NULL, + tstamp TIMESTAMP DEFAULT (datetime('now')) + )` + recordMigrationSQL = `INSERT INTO goose_db_version(version_id, is_applied) VALUES (?, 1)` + listMigrationStatesSQL = `SELECT version_id, is_applied, tstamp + FROM goose_db_version + ORDER BY id` +) //go:embed migrations/*.sql var embeddedMigrations embed.FS @@ -62,8 +73,7 @@ func applyMigrations(ctx context.Context, sqlDB *sql.DB) error { _ = tx.Rollback() return fmt.Errorf("apply migration %s: %w", item.Name, err) } - if _, err := tx.ExecContext(ctx, `INSERT INTO `+migrationsTableName+`(version_id, is_applied) - VALUES (?, 1)`, item.Version); err != nil { + if _, err := tx.ExecContext(ctx, recordMigrationSQL, item.Version); err != nil { _ = tx.Rollback() return fmt.Errorf("record migration %s: %w", item.Name, err) } @@ -129,19 +139,12 @@ type migrationStatus struct { } func ensureMigrationTable(ctx context.Context, sqlDB *sql.DB) error { - _, err := sqlDB.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS `+migrationsTableName+` ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - version_id INTEGER NOT NULL, - is_applied INTEGER NOT NULL, - tstamp TIMESTAMP DEFAULT (datetime('now')) - )`) + _, err := sqlDB.ExecContext(ctx, createMigrationsTableSQL) return err } func migrationStates(ctx context.Context, sqlDB *sql.DB) (map[int64]migrationState, error) { - rows, err := sqlDB.QueryContext(ctx, `SELECT version_id, is_applied, tstamp - FROM `+migrationsTableName+` - ORDER BY id ASC`) + rows, err := sqlDB.QueryContext(ctx, listMigrationStatesSQL) if err != nil { return nil, err } diff --git a/internal/db/migrations_test.go b/internal/db/migrations_test.go index 844430e..564daf1 100644 --- a/internal/db/migrations_test.go +++ b/internal/db/migrations_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "testing" + // Register the SQLite driver used by database/sql. _ "modernc.org/sqlite" ) @@ -47,57 +48,62 @@ func TestMigrationRunnerResumesExistingGooseVersionTable(t *testing.T) { ctx := context.Background() dbPath := filepath.Join(t.TempDir(), "partial.db") + status, err := preparePartialMigrations(ctx, dbPath, 3) + if err != nil { + t.Fatalf("prepare partial migrations: %v", err) + } + assertMigrationStates(t, status, 3) + + store, err := Open(ctx, dbPath) + if err != nil { + t.Fatalf("resume store: %v", err) + } + if err := store.Close(); err != nil { + t.Fatalf("close resumed store: %v", err) + } + + version, err := SchemaVersion(ctx, dbPath) + if err != nil { + t.Fatalf("schema version: %v", err) + } + if version != 5 { + t.Fatalf("expected resumed schema version 5, got %d", version) + } +} + +func preparePartialMigrations(ctx context.Context, dbPath string, count int) ([]MigrationStatus, error) { sqlDB, err := sql.Open("sqlite", dbPath) if err != nil { - t.Fatalf("open sqlite: %v", err) + return nil, err } + defer func() { _ = sqlDB.Close() }() if err := ensureMigrationTable(ctx, sqlDB); err != nil { - t.Fatalf("create goose table: %v", err) + return nil, err } migrations, err := loadMigrations() if err != nil { - t.Fatalf("load migrations: %v", err) + return nil, err } - for _, item := range migrations[:3] { + for _, item := range migrations[:count] { if _, err := sqlDB.ExecContext(ctx, item.SQL); err != nil { - t.Fatalf("apply legacy migration %s: %v", item.Name, err) + return nil, err } - if _, err := sqlDB.ExecContext(ctx, ` - INSERT INTO goose_db_version(version_id, is_applied) - VALUES (?, 1)`, item.Version); err != nil { - t.Fatalf("record legacy migration %s: %v", item.Name, err) + if _, err := sqlDB.ExecContext(ctx, recordMigrationSQL, item.Version); err != nil { + return nil, err } } - status, err := collectMigrationStatus(ctx, sqlDB) - if err != nil { - t.Fatalf("collect partial status: %v", err) - } + return collectMigrationStatus(ctx, sqlDB) +} + +func assertMigrationStates(t *testing.T, status []MigrationStatus, applied int) { + t.Helper() for index, item := range status { want := "pending" - if index < 3 { + if index < applied { want = "up" } if item.State != want { t.Fatalf("migration %d: expected state %q, got %#v", item.Version, want, item) } } - if err := sqlDB.Close(); err != nil { - t.Fatalf("close sqlite: %v", err) - } - - store, err := Open(ctx, dbPath) - if err != nil { - t.Fatalf("resume store: %v", err) - } - if err := store.Close(); err != nil { - t.Fatalf("close resumed store: %v", err) - } - - version, err := SchemaVersion(ctx, dbPath) - if err != nil { - t.Fatalf("schema version: %v", err) - } - if version != 5 { - t.Fatalf("expected resumed schema version 5, got %d", version) - } } diff --git a/internal/db/store.go b/internal/db/store.go index 57097ff..4098728 100644 --- a/internal/db/store.go +++ b/internal/db/store.go @@ -9,6 +9,7 @@ import ( "strings" "time" + // Register the SQLite driver used by database/sql. _ "modernc.org/sqlite" ) @@ -38,8 +39,26 @@ type Store struct { } const ( - maxTransactionAttempts = 6 - transactionRetryDelay = 20 * time.Millisecond + maxTransactionAttempts = 6 + transactionRetryDelay = 20 * time.Millisecond + recalculateDirectorySizesSQL = ` + WITH indexed_entries AS ( + SELECT + e.id, + CASE + WHEN substr(d.path, length(d.path), 1) = ? THEN d.path || e.name + ELSE d.path || ? || e.name + END AS full_path + FROM entries AS e + JOIN directories AS d ON d.id = e.dir_id + ) + SELECT target.path, e.size + FROM directory_size_targets AS target + JOIN indexed_entries AS i + ON i.full_path = target.path + OR substr(i.full_path, 1, length(target.prefix)) = target.prefix + JOIN entries AS e ON e.id = i.id + WHERE e.is_dir = 0` ) func Open(ctx context.Context, dbPath string) (*Store, error) { @@ -131,6 +150,26 @@ func (s *Store) migrateLegacyPathSchema(ctx context.Context) error { } defer func() { _ = tx.Rollback() }() + if err := prepareLegacyPathSchema(ctx, tx); err != nil { + return err + } + if err := copyLegacyEntries(ctx, tx); err != nil { + return err + } + if err := swapLegacyPathSchema(ctx, tx); err != nil { + return err + } + + if err := tx.Commit(); err != nil { + return err + } + if _, err := s.db.ExecContext(ctx, `VACUUM;`); err != nil { + return err + } + return s.ReindexFTS(ctx) +} + +func prepareLegacyPathSchema(ctx context.Context, tx *sql.Tx) error { setup := []string{ `DROP TRIGGER IF EXISTS entries_ai;`, `DROP TRIGGER IF EXISTS entries_ad;`, @@ -159,7 +198,10 @@ func (s *Store) migrateLegacyPathSchema(ctx context.Context) error { return err } } + return nil +} +func copyLegacyEntries(ctx context.Context, tx *sql.Tx) error { rows, err := tx.QueryContext(ctx, ` SELECT id, name, path, ext, size, mtime, is_dir, root, indexed_at FROM entries @@ -174,13 +216,11 @@ func (s *Store) migrateLegacyPathSchema(ctx context.Context) error { return err } defer func() { _ = insertDir.Close() }() - selectDirID, err := tx.PrepareContext(ctx, `SELECT id FROM directories WHERE path = ?`) if err != nil { return err } defer func() { _ = selectDirID.Close() }() - insertEntry, err := tx.PrepareContext(ctx, ` INSERT INTO entries_v2(id, name, dir_id, ext, size, mtime, is_dir, root, indexed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`) @@ -190,59 +230,50 @@ func (s *Store) migrateLegacyPathSchema(ctx context.Context) error { defer func() { _ = insertEntry.Close() }() for rows.Next() { - var ( - id int64 - name string - fullPath string - ext string - size int64 - mtime int64 - isDirInt int - root string - indexedAt int64 - ) - if err := rows.Scan(&id, &name, &fullPath, &ext, &size, &mtime, &isDirInt, &root, &indexedAt); err != nil { - return err - } - - dirPath, baseName := splitPath(fullPath) - if baseName == "" || baseName == "." { - baseName = name - } - if _, err := insertDir.ExecContext(ctx, dirPath); err != nil { - return err - } - - var dirID int64 - if err := selectDirID.QueryRowContext(ctx, dirPath).Scan(&dirID); err != nil { + if err := copyLegacyEntry(ctx, rows, insertDir, selectDirID, insertEntry); err != nil { return err } + } + return rows.Err() +} - if _, err := insertEntry.ExecContext(ctx, id, baseName, dirID, ext, size, mtime, isDirInt, root, indexedAt); err != nil { - return err - } +func copyLegacyEntry(ctx context.Context, rows *sql.Rows, insertDir, selectDirID, insertEntry *sql.Stmt) error { + var ( + id int64 + name string + fullPath string + ext string + size int64 + mtime int64 + isDirInt int + root string + indexedAt int64 + ) + if err := rows.Scan(&id, &name, &fullPath, &ext, &size, &mtime, &isDirInt, &root, &indexedAt); err != nil { + return err } - if err := rows.Err(); err != nil { + dirPath, baseName := splitPath(fullPath) + if baseName == "" || baseName == "." { + baseName = name + } + if _, err := insertDir.ExecContext(ctx, dirPath); err != nil { return err } - - swap := []string{ - `DROP TABLE entries;`, - `ALTER TABLE entries_v2 RENAME TO entries;`, + var dirID int64 + if err := selectDirID.QueryRowContext(ctx, dirPath).Scan(&dirID); err != nil { + return err } - for _, stmt := range swap { + _, err := insertEntry.ExecContext(ctx, id, baseName, dirID, ext, size, mtime, isDirInt, root, indexedAt) + return err +} + +func swapLegacyPathSchema(ctx context.Context, tx *sql.Tx) error { + for _, stmt := range []string{`DROP TABLE entries;`, `ALTER TABLE entries_v2 RENAME TO entries;`} { if _, err := tx.ExecContext(ctx, stmt); err != nil { return err } } - - if err := tx.Commit(); err != nil { - return err - } - if _, err := s.db.ExecContext(ctx, `VACUUM;`); err != nil { - return err - } - return s.ReindexFTS(ctx) + return nil } func (s *Store) tableExists(ctx context.Context, table string) (bool, error) { @@ -252,27 +283,18 @@ func (s *Store) tableExists(ctx context.Context, table string) (bool, error) { } func (s *Store) tableHasColumn(ctx context.Context, table, column string) (bool, error) { - rows, err := s.db.QueryContext(ctx, fmt.Sprintf(`PRAGMA table_info(%s)`, table)) + rows, err := s.db.QueryContext(ctx, `SELECT name FROM pragma_table_info(?) WHERE name = ?`, table, column) if err != nil { return false, err } defer func() { _ = rows.Close() }() - for rows.Next() { - var ( - cid int - name string - typ string - notNull int - dfltValue any - pk int - ) - if err := rows.Scan(&cid, &name, &typ, ¬Null, &dfltValue, &pk); err != nil { + if rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { return false, err } - if name == column { - return true, nil - } + return name == column, nil } if err := rows.Err(); err != nil { return false, err @@ -328,37 +350,47 @@ func (s *Store) withTxOnce(ctx context.Context, fn func(*sql.Tx) error) error { func upsertBatchTx(ctx context.Context, tx *sql.Tx, entries []Entry) error { now := time.Now().Unix() - dirSet := make(map[string]struct{}, len(entries)) + dirPaths := make(map[string]struct{}, len(entries)) for _, entry := range entries { dirPath, _ := splitPath(entry.Path) - dirSet[dirPath] = struct{}{} + dirPaths[dirPath] = struct{}{} + } + dirIDByPath, err := ensureDirectoryIDs(ctx, tx, dirPaths) + if err != nil { + return err } + return insertBatchEntries(ctx, tx, entries, dirIDByPath, now) +} +func ensureDirectoryIDs(ctx context.Context, tx *sql.Tx, paths map[string]struct{}) (map[string]int64, error) { insertDir, err := tx.PrepareContext(ctx, `INSERT INTO directories(path) VALUES (?) ON CONFLICT(path) DO NOTHING`) if err != nil { - return err + return nil, err } defer func() { _ = insertDir.Close() }() selectDirID, err := tx.PrepareContext(ctx, `SELECT id FROM directories WHERE path = ?`) if err != nil { - return err + return nil, err } defer func() { _ = selectDirID.Close() }() - for path := range dirSet { + for path := range paths { if _, err := insertDir.ExecContext(ctx, path); err != nil { - return err + return nil, err } } - dirIDByPath := make(map[string]int64, len(dirSet)) - for path := range dirSet { + dirIDByPath := make(map[string]int64, len(paths)) + for path := range paths { var id int64 if err := selectDirID.QueryRowContext(ctx, path).Scan(&id); err != nil { - return err + return nil, err } dirIDByPath[path] = id } + return dirIDByPath, nil +} +func insertBatchEntries(ctx context.Context, tx *sql.Tx, entries []Entry, dirIDByPath map[string]int64, now int64) error { entryStmt, err := tx.PrepareContext(ctx, ` INSERT INTO entries(name, dir_id, ext, size, mtime, is_dir, root, indexed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) @@ -402,7 +434,7 @@ func (s *Store) UpdateDirectorySizes(ctx context.Context, sizes map[string]int64 return nil } return s.withTx(ctx, func(tx *sql.Tx) error { - return updateDirectoryValuesTx(ctx, tx, "directory_size_updates", cleanSizes, false) + return updateDirectoryValuesTx(ctx, tx, cleanSizes, false) }) } @@ -429,7 +461,7 @@ func (s *Store) UpsertBatchWithDirectorySizes(ctx context.Context, entries []Ent if err := upsertBatchTx(ctx, tx, entries); err != nil { return err } - return updateDirectoryValuesTx(ctx, tx, "directory_size_deltas", deltas, true) + return updateDirectoryValuesTx(ctx, tx, deltas, true) }) } @@ -455,7 +487,7 @@ func (s *Store) DeleteByPrefixWithDirectorySize(ctx context.Context, prefix stri if total != 0 { deltas := make(map[string]int64) addAncestorDeltaForDir(deltas, filepath.Dir(prefix), -total) - if err := updateDirectoryValuesTx(ctx, tx, "directory_size_deltas", deltas, true); err != nil { + if err := updateDirectoryValuesTx(ctx, tx, deltas, true); err != nil { return err } } @@ -473,66 +505,79 @@ func (s *Store) RecalculateDirectorySizes(ctx context.Context, roots []string) e } return s.withTx(ctx, func(tx *sql.Tx) error { - directories := make(map[string]int64) - for _, root := range roots { - for dir := filepath.Clean(root); ; { - exists, err := directoryEntryExistsTx(ctx, tx, dir) - if err != nil { - return err - } - if !exists { - break - } - directories[dir] = 0 - parent := filepath.Dir(dir) - if parent == dir { - break - } - dir = parent - } + directories, err := collectDirectorySizes(ctx, tx, roots) + if err != nil { + return err } if len(directories) == 0 { return nil } - clauses := make([]string, 0, len(directories)) - args := []any{pathSeparator(), pathSeparator()} - for path := range directories { - prefix := pathPrefix(path) - clauses = append(clauses, `(i.full_path = ? OR substr(i.full_path, 1, length(?)) = ?)`) - args = append(args, path, prefix, prefix) + if err := createDirectorySizeTargets(ctx, tx, directories); err != nil { + return err } - query := fullPathCTE() + ` - SELECT i.full_path, e.size - FROM indexed_entries AS i - JOIN entries AS e ON e.id = i.id - WHERE e.is_dir = 0 AND (` + strings.Join(clauses, " OR ") + `)` - rows, err := tx.QueryContext(ctx, query, args...) - if err != nil { + defer func() { _, _ = tx.ExecContext(context.Background(), `DROP TABLE IF EXISTS directory_size_targets`) }() + if err := collectDirectorySizeTotals(ctx, tx, directories); err != nil { return err } - for rows.Next() { - var path string - var size int64 - if err := rows.Scan(&path, &size); err != nil { - _ = rows.Close() - return err + return updateDirectoryValuesTx(ctx, tx, directories, false) + }) +} + +func collectDirectorySizeTotals(ctx context.Context, tx *sql.Tx, directories map[string]int64) error { + rows, err := tx.QueryContext(ctx, recalculateDirectorySizesSQL, pathSeparator(), pathSeparator()) + if err != nil { + return err + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var root string + var size int64 + if err := rows.Scan(&root, &size); err != nil { + return err + } + directories[root] += size + } + return rows.Err() +} + +func collectDirectorySizes(ctx context.Context, tx *sql.Tx, roots []string) (map[string]int64, error) { + directories := make(map[string]int64) + for _, root := range roots { + for dir := filepath.Clean(root); ; { + exists, err := directoryEntryExistsTx(ctx, tx, dir) + if err != nil { + return nil, err } - for dir := range directories { - if isWithinPath(dir, path) { - directories[dir] += size - } + if !exists { + break } + directories[dir] = 0 + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent } - if err := rows.Err(); err != nil { - _ = rows.Close() - return err - } - if err := rows.Close(); err != nil { + } + return directories, nil +} + +func createDirectorySizeTargets(ctx context.Context, tx *sql.Tx, directories map[string]int64) error { + if _, err := tx.ExecContext(ctx, `CREATE TEMP TABLE directory_size_targets (path TEXT PRIMARY KEY, prefix TEXT NOT NULL)`); err != nil { + return err + } + stmt, err := tx.PrepareContext(ctx, `INSERT INTO directory_size_targets(path, prefix) VALUES (?, ?)`) + if err != nil { + return err + } + defer func() { _ = stmt.Close() }() + for path := range directories { + if _, err := stmt.ExecContext(ctx, path, pathPrefix(path)); err != nil { return err } - return updateDirectoryValuesTx(ctx, tx, "directory_size_updates", directories, false) - }) + } + return nil } type txExecutor interface { @@ -542,16 +587,16 @@ type txExecutor interface { QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row } -func updateDirectoryValuesTx(ctx context.Context, tx txExecutor, table string, values map[string]int64, delta bool) error { +func updateDirectoryValuesTx(ctx context.Context, tx txExecutor, values map[string]int64, delta bool) error { if len(values) == 0 { return nil } - if _, err := tx.ExecContext(ctx, `CREATE TEMP TABLE `+table+` (path TEXT PRIMARY KEY, value INTEGER NOT NULL)`); err != nil { + if _, err := tx.ExecContext(ctx, `CREATE TEMP TABLE directory_size_values (path TEXT PRIMARY KEY, value INTEGER NOT NULL)`); err != nil { return err } - defer func() { _, _ = tx.ExecContext(context.Background(), `DROP TABLE IF EXISTS `+table) }() + defer func() { _, _ = tx.ExecContext(context.Background(), `DROP TABLE IF EXISTS directory_size_values`) }() - stmt, err := tx.PrepareContext(ctx, `INSERT INTO `+table+`(path, value) VALUES (?, ?) ON CONFLICT(path) DO UPDATE SET value = excluded.value`) + stmt, err := tx.PrepareContext(ctx, `INSERT INTO directory_size_values(path, value) VALUES (?, ?) ON CONFLICT(path) DO UPDATE SET value = excluded.value`) if err != nil { return err } @@ -569,15 +614,13 @@ func updateDirectoryValuesTx(ctx context.Context, tx txExecutor, table string, v return err } - operator := "value" - if delta { - operator = "CASE WHEN entries.size + value < 0 THEN 0 ELSE entries.size + value END" - } query := fullPathCTE() + ` UPDATE entries - SET size = (SELECT ` + operator + ` - FROM ` + table + ` - WHERE ` + table + `.path = ( + SET size = (SELECT CASE WHEN ? THEN + CASE WHEN entries.size + value < 0 THEN 0 ELSE entries.size + value END + ELSE value END + FROM directory_size_values + WHERE directory_size_values.path = ( SELECT indexed_entries.full_path FROM indexed_entries WHERE indexed_entries.id = entries.id @@ -586,9 +629,9 @@ func updateDirectoryValuesTx(ctx context.Context, tx txExecutor, table string, v AND id IN ( SELECT indexed_entries.id FROM indexed_entries - JOIN ` + table + ` ON ` + table + `.path = indexed_entries.full_path + JOIN directory_size_values ON directory_size_values.path = indexed_entries.full_path )` - _, err = tx.ExecContext(ctx, query, pathSeparator(), pathSeparator()) + _, err = tx.ExecContext(ctx, query, pathSeparator(), pathSeparator(), delta) return err } diff --git a/internal/db/store_test.go b/internal/db/store_test.go index f2a2293..754c6da 100644 --- a/internal/db/store_test.go +++ b/internal/db/store_test.go @@ -10,14 +10,50 @@ import ( "testing" "time" + // Register the SQLite driver used by database/sql. _ "modernc.org/sqlite" ) +const ( + storeTestDBFile = "test.db" + storeTestMainGo = "main.go" + storeTestIndexJS = "index.js" + storeTestOpenDB = "open db: %v" + storeTestUpsert = "upsert: %v" +) + +func TestTableHasColumnUsesParameterizedPragma(t *testing.T) { + ctx := context.Background() + store, err := Open(ctx, filepath.Join(t.TempDir(), "columns.db")) + if err != nil { + t.Fatalf(storeTestOpenDB, err) + } + defer func() { _ = store.Close() }() + + for _, test := range []struct { + table string + column string + want bool + }{ + {table: "entries", column: "dir_id", want: true}, + {table: "entries", column: "path", want: false}, + {table: "entries' OR 1=1 --", column: "dir_id", want: false}, + } { + got, err := store.tableHasColumn(ctx, test.table, test.column) + if err != nil { + t.Fatalf("tableHasColumn(%q, %q): %v", test.table, test.column, err) + } + if got != test.want { + t.Errorf("tableHasColumn(%q, %q): want %t, got %t", test.table, test.column, test.want, got) + } + } +} + func TestStoreRetriesBusyTransactions(t *testing.T) { ctx := context.Background() store, err := Open(ctx, filepath.Join(t.TempDir(), "retry.db")) if err != nil { - t.Fatalf("open db: %v", err) + t.Fatalf(storeTestOpenDB, err) } defer func() { _ = store.Close() }() @@ -51,11 +87,11 @@ func TestStoreSearchFTSAndWildcard(t *testing.T) { t.Parallel() ctx := context.Background() - dbPath := filepath.Join(t.TempDir(), "test.db") + dbPath := filepath.Join(t.TempDir(), storeTestDBFile) store, err := Open(ctx, dbPath) if err != nil { - t.Fatalf("open db: %v", err) + t.Fatalf(storeTestOpenDB, err) } defer func() { _ = store.Close() }() @@ -64,7 +100,7 @@ func TestStoreSearchFTSAndWildcard(t *testing.T) { NewEntryFromPath("/tmp", "/tmp/another.log", 20, time.Now(), false), } if err := store.UpsertBatch(ctx, entries); err != nil { - t.Fatalf("upsert: %v", err) + t.Fatalf(storeTestUpsert, err) } res, err := store.SearchAdvanced(ctx, SearchOptions{Query: "my_rep", Limit: 10, Offset: 0}) @@ -88,11 +124,11 @@ func TestStoreSearchAdvancedFiltersAndReindex(t *testing.T) { t.Parallel() ctx := context.Background() - dbPath := filepath.Join(t.TempDir(), "test.db") + dbPath := filepath.Join(t.TempDir(), storeTestDBFile) store, err := Open(ctx, dbPath) if err != nil { - t.Fatalf("open db: %v", err) + t.Fatalf(storeTestOpenDB, err) } defer func() { _ = store.Close() }() @@ -100,11 +136,11 @@ func TestStoreSearchAdvancedFiltersAndReindex(t *testing.T) { root := testPath("Users", "a") entries := []Entry{ NewEntryFromPath(root, filepath.Join(root, "report.txt"), 10, now, false), - NewEntryFromPath(root, filepath.Join(root, "src", "main.go"), 20, now, false), + NewEntryFromPath(root, filepath.Join(root, "src", storeTestMainGo), 20, now, false), NewEntryFromPath(root, filepath.Join(root, "src"), 0, now, true), } if err := store.UpsertBatch(ctx, entries); err != nil { - t.Fatalf("upsert: %v", err) + t.Fatalf(storeTestUpsert, err) } got, err := store.SearchAdvanced(ctx, SearchOptions{ @@ -128,7 +164,7 @@ func TestStoreSearchAdvancedFiltersAndReindex(t *testing.T) { if err != nil { t.Fatalf("search after reindex: %v", err) } - if len(got) != 1 || got[0].Name != "main.go" { + if len(got) != 1 || got[0].Name != storeTestMainGo { t.Fatalf("unexpected results after reindex: %+v", got) } } @@ -137,22 +173,22 @@ func TestStoreSearchDoesNotMatchPathSegments(t *testing.T) { t.Parallel() ctx := context.Background() - dbPath := filepath.Join(t.TempDir(), "test.db") + dbPath := filepath.Join(t.TempDir(), storeTestDBFile) store, err := Open(ctx, dbPath) if err != nil { - t.Fatalf("open db: %v", err) + t.Fatalf(storeTestOpenDB, err) } defer func() { _ = store.Close() }() now := time.Now() root := testPath("Users", "a") entries := []Entry{ - NewEntryFromPath(root, filepath.Join(root, "projects", "go", "main.go"), 20, now, false), - NewEntryFromPath(root, filepath.Join(root, "projects", "js", "index.js"), 20, now, false), + NewEntryFromPath(root, filepath.Join(root, "projects", "go", storeTestMainGo), 20, now, false), + NewEntryFromPath(root, filepath.Join(root, "projects", "js", storeTestIndexJS), 20, now, false), } if err := store.UpsertBatch(ctx, entries); err != nil { - t.Fatalf("upsert: %v", err) + t.Fatalf(storeTestUpsert, err) } got, err := store.SearchAdvanced(ctx, SearchOptions{ @@ -171,11 +207,11 @@ func TestStoreDirectoryDedupAndDelete(t *testing.T) { t.Parallel() ctx := context.Background() - dbPath := filepath.Join(t.TempDir(), "test.db") + dbPath := filepath.Join(t.TempDir(), storeTestDBFile) store, err := Open(ctx, dbPath) if err != nil { - t.Fatalf("open db: %v", err) + t.Fatalf(storeTestOpenDB, err) } defer func() { _ = store.Close() }() @@ -183,12 +219,12 @@ func TestStoreDirectoryDedupAndDelete(t *testing.T) { root := testPath("Users", "a") goDir := filepath.Join(root, "projects", "go") entries := []Entry{ - NewEntryFromPath(root, filepath.Join(goDir, "main.go"), 10, now, false), + NewEntryFromPath(root, filepath.Join(goDir, storeTestMainGo), 10, now, false), NewEntryFromPath(root, filepath.Join(goDir, "utils.go"), 12, now, false), - NewEntryFromPath(root, filepath.Join(root, "projects", "js", "index.js"), 14, now, false), + NewEntryFromPath(root, filepath.Join(root, "projects", "js", storeTestIndexJS), 14, now, false), } if err := store.UpsertBatch(ctx, entries); err != nil { - t.Fatalf("upsert: %v", err) + t.Fatalf(storeTestUpsert, err) } var dirs int @@ -221,7 +257,7 @@ func TestStoreUpdateDirectorySizesBatch(t *testing.T) { ctx := context.Background() store, err := Open(ctx, filepath.Join(t.TempDir(), "sizes.db")) if err != nil { - t.Fatalf("open db: %v", err) + t.Fatalf(storeTestOpenDB, err) } defer func() { _ = store.Close() }() @@ -236,7 +272,7 @@ func TestStoreUpdateDirectorySizesBatch(t *testing.T) { NewEntryFromPath(root, filepath.Join(nested, "two.bin"), 30, time.Now(), false), } if err := store.UpsertBatch(ctx, entries); err != nil { - t.Fatalf("upsert: %v", err) + t.Fatalf(storeTestUpsert, err) } if err := store.UpdateDirectorySizes(ctx, map[string]int64{root: 42, nested: 42, empty: 0}); err != nil { t.Fatalf("update directory sizes: %v", err) @@ -250,13 +286,53 @@ func TestStoreUpdateDirectorySizesBatch(t *testing.T) { } } +func TestStoreRecalculateDirectorySizesSeparatesSimilarPrefixes(t *testing.T) { + t.Parallel() + + ctx := context.Background() + store, err := Open(ctx, filepath.Join(t.TempDir(), "recalculate.db")) + if err != nil { + t.Fatalf(storeTestOpenDB, err) + } + defer func() { _ = store.Close() }() + + base := testPath("sizes", "root") + foo := filepath.Join(base, "foo") + nested := filepath.Join(foo, "nested") + foobar := filepath.Join(base, "foobar") + entries := []Entry{ + NewEntryFromPath(base, base, 0, time.Now(), true), + NewEntryFromPath(base, foo, 0, time.Now(), true), + NewEntryFromPath(base, nested, 0, time.Now(), true), + NewEntryFromPath(base, foobar, 0, time.Now(), true), + NewEntryFromPath(base, filepath.Join(nested, "inside.bin"), 7, time.Now(), false), + NewEntryFromPath(base, filepath.Join(foobar, "outside.bin"), 90, time.Now(), false), + } + if err := store.UpsertBatch(ctx, entries); err != nil { + t.Fatalf(storeTestUpsert, err) + } + if err := store.RecalculateDirectorySizes(ctx, []string{foo, nested}); err != nil { + t.Fatalf("recalculate: %v", err) + } + + if got := findEntryByPath(t, store, foo, true).Size; got != 7 { + t.Fatalf("foo size: want 7, got %d", got) + } + if got := findEntryByPath(t, store, nested, true).Size; got != 7 { + t.Fatalf("nested size: want 7, got %d", got) + } + if got := findEntryByPath(t, store, foobar, true).Size; got != 0 { + t.Fatalf("foobar size changed through similar prefix: want 0, got %d", got) + } +} + func TestStoreTopEntries(t *testing.T) { t.Parallel() ctx := context.Background() store, err := Open(ctx, filepath.Join(t.TempDir(), "usage.db")) if err != nil { - t.Fatalf("open db: %v", err) + t.Fatalf(storeTestOpenDB, err) } defer func() { _ = store.Close() }() @@ -276,7 +352,7 @@ func TestStoreTopEntries(t *testing.T) { entries = append(entries, NewEntryFromPath(root, filepath.Join(root, fmt.Sprintf("extra-%02d.bin", i)), int64(i), time.Now(), false)) } if err := store.UpsertBatch(ctx, entries); err != nil { - t.Fatalf("upsert: %v", err) + t.Fatalf(storeTestUpsert, err) } if err := store.UpdateDirectorySizes(ctx, map[string]int64{root: 281, large: 80, small: 20, nested: 80}); err != nil { t.Fatalf("update sizes: %v", err) @@ -300,7 +376,7 @@ func TestStoreWatcherDirectorySizeDeltas(t *testing.T) { ctx := context.Background() store, err := Open(ctx, filepath.Join(t.TempDir(), "watcher-sizes.db")) if err != nil { - t.Fatalf("open db: %v", err) + t.Fatalf(storeTestOpenDB, err) } defer func() { _ = store.Close() }() @@ -347,7 +423,7 @@ func TestStoreDirectoryDeleteSubtractsSubtreeSize(t *testing.T) { ctx := context.Background() store, err := Open(ctx, filepath.Join(t.TempDir(), "subtree-sizes.db")) if err != nil { - t.Fatalf("open db: %v", err) + t.Fatalf(storeTestOpenDB, err) } defer func() { _ = store.Close() }() @@ -363,7 +439,7 @@ func TestStoreDirectoryDeleteSubtractsSubtreeSize(t *testing.T) { NewEntryFromPath(root, filepath.Join(keep, "c.bin"), 5, time.Now(), false), } if err := store.UpsertBatch(ctx, entries); err != nil { - t.Fatalf("upsert: %v", err) + t.Fatalf(storeTestUpsert, err) } if err := store.UpdateDirectorySizes(ctx, map[string]int64{root: 23, removed: 18, keep: 5}); err != nil { t.Fatalf("initial sizes: %v", err) @@ -402,25 +478,25 @@ func TestStoreDeleteByPrefixRemovesNestedDescendants(t *testing.T) { t.Parallel() ctx := context.Background() - dbPath := filepath.Join(t.TempDir(), "test.db") + dbPath := filepath.Join(t.TempDir(), storeTestDBFile) store, err := Open(ctx, dbPath) if err != nil { - t.Fatalf("open db: %v", err) + t.Fatalf(storeTestOpenDB, err) } defer func() { _ = store.Close() }() now := time.Now() root := testPath("Users", "a") prefix := filepath.Join(root, "projects", "go") - keepPath := filepath.Join(root, "projects", "js", "index.js") + keepPath := filepath.Join(root, "projects", "js", storeTestIndexJS) entries := []Entry{ - NewEntryFromPath(root, filepath.Join(prefix, "pkg", "main.go"), 10, now, false), + NewEntryFromPath(root, filepath.Join(prefix, "pkg", storeTestMainGo), 10, now, false), NewEntryFromPath(root, filepath.Join(prefix, "pkg", "readme.md"), 12, now, false), NewEntryFromPath(root, keepPath, 14, now, false), } if err := store.UpsertBatch(ctx, entries); err != nil { - t.Fatalf("upsert: %v", err) + t.Fatalf(storeTestUpsert, err) } if err := store.DeleteByPrefix(ctx, prefix); err != nil { @@ -448,11 +524,11 @@ func TestStoreFinishScanPrunesMissingAndKeepsProtectedPrefixes(t *testing.T) { t.Parallel() ctx := context.Background() - dbPath := filepath.Join(t.TempDir(), "test.db") + dbPath := filepath.Join(t.TempDir(), storeTestDBFile) store, err := Open(ctx, dbPath) if err != nil { - t.Fatalf("open db: %v", err) + t.Fatalf(storeTestOpenDB, err) } defer func() { _ = store.Close() }() @@ -463,7 +539,7 @@ func TestStoreFinishScanPrunesMissingAndKeepsProtectedPrefixes(t *testing.T) { stale := NewEntryFromPath(root, filepath.Join(root, "stale.txt"), 10, now, false) protected := NewEntryFromPath(root, filepath.Join(protectedDir, "secret.txt"), 10, now, false) if err := store.UpsertBatch(ctx, []Entry{keep, stale, protected}); err != nil { - t.Fatalf("upsert: %v", err) + t.Fatalf(storeTestUpsert, err) } sessionID, err := store.BeginScan(ctx, []string{root}) @@ -499,13 +575,13 @@ func TestStoreMigratesLegacySchema(t *testing.T) { legacySQL, err := sql.Open("sqlite", dbPath) if err != nil { - t.Fatalf("open db: %v", err) + t.Fatalf(storeTestOpenDB, err) } defer func() { _ = legacySQL.Close() }() // Simulate legacy schema with path stored directly in entries and FTS over path+name. root := testPath("Users", "a") - fullPath := filepath.Join(root, "projects", "go", "main.go") + fullPath := filepath.Join(root, "projects", "go", storeTestMainGo) legacySchema := []string{ `DROP TRIGGER IF EXISTS entries_ai;`, `DROP TRIGGER IF EXISTS entries_ad;`, diff --git a/internal/scanner/ntfs_windows.go b/internal/scanner/ntfs_windows.go index 82d27b9..7fe717c 100644 --- a/internal/scanner/ntfs_windows.go +++ b/internal/scanner/ntfs_windows.go @@ -147,8 +147,14 @@ func scanNTFSVolume(ctx context.Context, plan ntfsVolumePlan, emit func(db.Entry build := func(frn uint64) (string, bool) { return buildNTFSPath(plan.root, frn, records, pathByFRN, map[uint64]bool{}) } + if err := emitNTFSRequestedRoots(plan.paths, emit, progress); err != nil { + return err + } + return emitNTFSRecords(ctx, plan.paths, records, build, emit, progress) +} - for _, requestedRoot := range plan.paths { +func emitNTFSRequestedRoots(roots []string, emit func(db.Entry) error, progress scanProgress) error { + for _, requestedRoot := range roots { info, statErr := os.Stat(requestedRoot) if statErr != nil { continue @@ -160,7 +166,10 @@ func scanNTFSVolume(ctx context.Context, plan ntfsVolumePlan, emit func(db.Entry return err } } + return nil +} +func emitNTFSRecords(ctx context.Context, roots []string, records map[uint64]ntfsRecord, build func(uint64) (string, bool), emit func(db.Entry) error, progress scanProgress) error { for frn, record := range records { select { case <-ctx.Done(): @@ -171,10 +180,10 @@ func scanNTFSVolume(ctx context.Context, plan ntfsVolumePlan, emit func(db.Entry continue } path, ok := build(frn) - if !ok || !pathWithinAnyRoot(path, plan.paths) { + if !ok || !pathWithinAnyRoot(path, roots) { continue } - root := matchingRoot(path, plan.paths) + root := matchingRoot(path, roots) if root == "" { continue } diff --git a/internal/scanner/roots_windows.go b/internal/scanner/roots_windows.go index 1554e62..d89d55a 100644 --- a/internal/scanner/roots_windows.go +++ b/internal/scanner/roots_windows.go @@ -24,7 +24,13 @@ func DiscoverRoots() []string { return []string{`C:\`} } } + if roots := parseLogicalDriveRoots(buf, n); len(roots) > 0 { + return roots + } + return []string{`C:\`} +} +func parseLogicalDriveRoots(buf []uint16, n uint32) []string { roots := make([]string, 0) start := 0 for i := 0; i < int(n); i++ { @@ -40,9 +46,6 @@ func DiscoverRoots() []string { } start = i + 1 } - if len(roots) == 0 { - return []string{`C:\`} - } sort.Strings(roots) return roots } diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index 456c831..fba22f3 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -50,7 +50,7 @@ const ( BackendNTFS = "ntfs" ) -type scanBackend interface { +type scanner interface { Scan(ctx context.Context, roots []string, emit func(db.Entry) error, progress scanProgress) error } @@ -172,168 +172,229 @@ func (r Runner) Scan(ctx context.Context, roots []string) (Metrics, error) { if batchSize <= 0 { batchSize = 2000 } - - entriesCh := make(chan db.Entry, batchSize*2) - errCh := make(chan error, 1) - - var ( - scanned int64 - indexed int64 - skipped int64 - ) - sizes := newDirectorySizeAccumulator() - var currentPath atomic.Value - protected := make(map[string]struct{}) - var protectedMu sync.Mutex - - emitProgress := func() { - if r.Progress == nil { - return - } - elapsed := time.Since(start) - scannedNow := atomic.LoadInt64(&scanned) - progress := Progress{ - Scanned: scannedNow, - Indexed: atomic.LoadInt64(&indexed), - Skipped: atomic.LoadInt64(&skipped), - Elapsed: elapsed, - CurrentPath: "", - } - if path, ok := currentPath.Load().(string); ok { - progress.CurrentPath = path - } - if elapsed > 0 { - progress.FilesPerSecond = float64(scannedNow) / elapsed.Seconds() - } - r.Progress(progress) + result, err := r.runScanBackend(ctx, roots, sessionID, batchSize, start) + if err != nil { + return Metrics{}, err + } + if err := r.finishScan(ctx, sessionID, roots, result.protectedPaths); err != nil { + return Metrics{}, err } + sessionID = 0 + if err := r.Store.UpdateDirectorySizes(ctx, result.sizes); err != nil { + return Metrics{}, err + } + result.emitProgress() + return result.metrics, nil +} - writerDone := make(chan struct{}) - go func() { - defer close(writerDone) - batch := make([]db.Entry, 0, batchSize) - flush := func() error { - if len(batch) == 0 { - return nil - } - if err := r.Store.UpsertBatch(ctx, batch); err != nil { - return err - } - if err := r.Store.MarkSeenBatch(ctx, sessionID, batch); err != nil { - return err - } - atomic.AddInt64(&indexed, int64(len(batch))) - batch = batch[:0] - emitProgress() - return nil - } +type scanRunResult struct { + metrics Metrics + sizes map[string]int64 + protectedPaths []string + emitProgress func() +} - for { - select { - case <-ctx.Done(): - return - case entry, ok := <-entriesCh: - if !ok { - if err := flush(); err != nil { - sendErr(errCh, err) - } - return - } - batch = append(batch, entry) - if len(batch) >= batchSize { - if err := flush(); err != nil { - sendErr(errCh, err) - return - } - } - } - } - }() +type scanCollector struct { + start time.Time + progress func(Progress) + scanned int64 + indexed int64 + skipped int64 + currentPath atomic.Value + sizes *directorySizeAccumulator + protected map[string]struct{} + protectedMu sync.Mutex +} - emitEntry := func(entry db.Entry) error { - sizes.add(entry) - select { - case entriesCh <- entry: - return nil - case <-ctx.Done(): - return ctx.Err() - } +func newScanCollector(start time.Time, progress func(Progress)) *scanCollector { + return &scanCollector{ + start: start, + progress: progress, + sizes: newDirectorySizeAccumulator(), + protected: make(map[string]struct{}), } - progress := scanProgress{ - Scanned: &scanned, - Skipped: &skipped, - CurrentPath: ¤tPath, - Emit: emitProgress, - Protect: func(path string) { - path = strings.TrimSpace(path) - if path == "" { - return - } - protectedMu.Lock() - protected[filepath.Clean(path)] = struct{}{} - protectedMu.Unlock() - }, - } - if err := r.backend().Scan(ctx, roots, emitEntry, progress); err != nil { - close(entriesCh) - <-writerDone - return Metrics{}, err +} + +func (c *scanCollector) emitProgress() { + if c.progress == nil { + return + } + elapsed := time.Since(c.start) + scanned := atomic.LoadInt64(&c.scanned) + progress := Progress{ + Scanned: scanned, + Indexed: atomic.LoadInt64(&c.indexed), + Skipped: atomic.LoadInt64(&c.skipped), + Elapsed: elapsed, + CurrentPath: "", + } + if path, ok := c.currentPath.Load().(string); ok { + progress.CurrentPath = path + } + if elapsed > 0 { + progress.FilesPerSecond = float64(scanned) / elapsed.Seconds() } + c.progress(progress) +} - if ctx.Err() != nil { - close(entriesCh) - <-writerDone - return Metrics{}, ctx.Err() +func (c *scanCollector) progressState() scanProgress { + return scanProgress{ + Scanned: &c.scanned, + Skipped: &c.skipped, + CurrentPath: &c.currentPath, + Emit: c.emitProgress, + Protect: c.protect, } +} - close(entriesCh) - <-writerDone +func (c *scanCollector) protect(path string) { + path = strings.TrimSpace(path) + if path == "" { + return + } + c.protectedMu.Lock() + c.protected[filepath.Clean(path)] = struct{}{} + c.protectedMu.Unlock() +} +func (c *scanCollector) emitEntry(ctx context.Context, entriesCh chan<- db.Entry, entry db.Entry) error { + c.sizes.add(entry) select { - case err := <-errCh: - return Metrics{}, err - default: - } - if ctx.Err() != nil { - return Metrics{}, ctx.Err() + case entriesCh <- entry: + return nil + case <-ctx.Done(): + return ctx.Err() } - elapsed := time.Since(start) +} + +func (c *scanCollector) metrics() Metrics { + elapsed := time.Since(c.start) metrics := Metrics{ - Scanned: atomic.LoadInt64(&scanned), - Indexed: atomic.LoadInt64(&indexed), - Skipped: atomic.LoadInt64(&skipped), + Scanned: atomic.LoadInt64(&c.scanned), + Indexed: atomic.LoadInt64(&c.indexed), + Skipped: atomic.LoadInt64(&c.skipped), Elapsed: elapsed, } if elapsed > 0 { metrics.FilesPerSecond = float64(metrics.Scanned) / elapsed.Seconds() } - protectedMu.Lock() - protectedPaths := make([]string, 0, len(protected)) - for path := range protected { - protectedPaths = append(protectedPaths, path) + return metrics +} + +func (c *scanCollector) protectedPaths() []string { + c.protectedMu.Lock() + defer c.protectedMu.Unlock() + paths := make([]string, 0, len(c.protected)) + for path := range c.protected { + paths = append(paths, path) } - protectedMu.Unlock() + return paths +} - for _, path := range protectedPaths { - if err := r.Store.MarkUnreadablePrefix(ctx, sessionID, path); err != nil { - return Metrics{}, err - } +func (r Runner) runScanBackend(ctx context.Context, roots []string, sessionID int64, batchSize int, start time.Time) (scanRunResult, error) { + collector := newScanCollector(start, r.Progress) + entriesCh := make(chan db.Entry, batchSize*2) + errCh := make(chan error, 1) + writerDone := make(chan struct{}) + go scanWriter{ + ctx: ctx, + store: r.Store, + sessionID: sessionID, + batchSize: batchSize, + entries: entriesCh, + errors: errCh, + indexed: &collector.indexed, + emitProgress: collector.emitProgress, + done: writerDone, + }.run() + + backendErr := r.backend().Scan(ctx, roots, func(entry db.Entry) error { + return collector.emitEntry(ctx, entriesCh, entry) + }, collector.progressState()) + close(entriesCh) + <-writerDone + if backendErr != nil { + return scanRunResult{}, backendErr } - if err := r.Store.FinishScan(ctx, sessionID, roots); err != nil { - return Metrics{}, err + select { + case err := <-errCh: + return scanRunResult{}, err + default: } - sessionID = 0 - - if err := r.Store.UpdateDirectorySizes(ctx, sizes.snapshot()); err != nil { - return Metrics{}, err + if err := ctx.Err(); err != nil { + return scanRunResult{}, err } + return scanRunResult{ + metrics: collector.metrics(), + sizes: collector.sizes.snapshot(), + protectedPaths: collector.protectedPaths(), + emitProgress: collector.emitProgress, + }, nil +} - emitProgress() +type scanWriter struct { + ctx context.Context + store *db.Store + sessionID int64 + batchSize int + entries <-chan db.Entry + errors chan<- error + indexed *int64 + emitProgress func() + done chan<- struct{} +} - return metrics, nil +func (w scanWriter) run() { + defer close(w.done) + batch := make([]db.Entry, 0, w.batchSize) + for { + select { + case <-w.ctx.Done(): + return + case entry, ok := <-w.entries: + if !ok { + if _, err := w.flush(batch); err != nil { + sendErr(w.errors, err) + } + return + } + batch = append(batch, entry) + if len(batch) >= w.batchSize { + var err error + batch, err = w.flush(batch) + if err != nil { + sendErr(w.errors, err) + return + } + } + } + } +} + +func (w scanWriter) flush(batch []db.Entry) ([]db.Entry, error) { + if len(batch) == 0 { + return batch, nil + } + if err := w.store.UpsertBatch(w.ctx, batch); err != nil { + return batch, err + } + if err := w.store.MarkSeenBatch(w.ctx, w.sessionID, batch); err != nil { + return batch, err + } + atomic.AddInt64(w.indexed, int64(len(batch))) + w.emitProgress() + return batch[:0], nil +} +func (r Runner) finishScan(ctx context.Context, sessionID int64, roots []string, protectedPaths []string) error { + for _, path := range protectedPaths { + if err := r.Store.MarkUnreadablePrefix(ctx, sessionID, path); err != nil { + return err + } + } + return r.Store.FinishScan(ctx, sessionID, roots) } -func (r Runner) backend() scanBackend { +func (r Runner) backend() scanner { backend := strings.ToLower(strings.TrimSpace(r.Backend)) if backend == "" { backend = BackendAuto @@ -355,7 +416,7 @@ func (r Runner) walkBackend() walkBackend { return walkBackend{workers: workers, exclude: r.Exclude} } -func sendErr(errCh chan error, err error) { +func sendErr(errCh chan<- error, err error) { select { case errCh <- err: default: @@ -384,51 +445,9 @@ func (b walkBackend) Scan(ctx context.Context, roots []string, emit func(db.Entr for _, root := range roots { root := root exclude := newExcludeMatcher(root, b.exclude) - err := fastwalk.Walk(&fastwalk.Config{Follow: false, NumWorkers: b.workers}, root, func(path string, d fs.DirEntry, err error) error { - if err != nil { - progress.CurrentPath.Store(path) - atomic.AddInt64(progress.Skipped, 1) - progress.Emit() - if filepath.Clean(path) == filepath.Clean(root) { - return err - } - if progress.Protect != nil { - progress.Protect(path) - } - return nil - } - - if d.IsDir() && mountFilter != nil && mountFilter(root, path) { - return fastwalk.SkipDir - } - if exclude(path, d.IsDir()) { - if d.IsDir() { - return fastwalk.SkipDir - } - progress.CurrentPath.Store(path) - atomic.AddInt64(progress.Skipped, 1) - progress.Emit() - return nil - } - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - info, infoErr := d.Info() - if infoErr != nil { - atomic.AddInt64(progress.Skipped, 1) - if progress.Protect != nil { - progress.Protect(path) - } - return nil - } - - progress.CurrentPath.Store(path) - atomic.AddInt64(progress.Scanned, 1) - progress.Emit() - return emit(db.NewEntryFromPath(root, path, info.Size(), info.ModTime(), d.IsDir())) + handler := walkEntryHandler{ctx: ctx, root: root, mountFilter: mountFilter, exclude: exclude, emit: emit, progress: progress} + err := fastwalk.Walk(&fastwalk.Config{Follow: false, NumWorkers: b.workers}, root, func(path string, d fs.DirEntry, walkErr error) error { + return handler.handle(path, d, walkErr) }) if err != nil && !errors.Is(err, context.Canceled) { return err @@ -437,16 +456,65 @@ func (b walkBackend) Scan(ctx context.Context, roots []string, emit func(db.Entr return nil } -func newExcludeMatcher(root string, patterns []string) func(path string, isDir bool) bool { - normalized := make([]string, 0, len(patterns)) - for _, pattern := range patterns { - trimmed := strings.TrimSpace(pattern) - if trimmed == "" { - continue +type walkEntryHandler struct { + ctx context.Context + root string + mountFilter func(root, path string) bool + exclude func(string, bool) bool + emit func(db.Entry) error + progress scanProgress +} + +func (h walkEntryHandler) handle(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + h.progress.CurrentPath.Store(path) + atomic.AddInt64(h.progress.Skipped, 1) + h.progress.Emit() + if filepath.Clean(path) == filepath.Clean(h.root) { + return walkErr + } + if h.progress.Protect != nil { + h.progress.Protect(path) } - normalized = append(normalized, filepath.Clean(trimmed)) + return nil + } + if d.IsDir() && h.mountFilter != nil && h.mountFilter(h.root, path) { + return fastwalk.SkipDir } + if h.exclude(path, d.IsDir()) { + return skipWalkEntry(path, d.IsDir(), h.progress) + } + select { + case <-h.ctx.Done(): + return h.ctx.Err() + default: + } + info, infoErr := d.Info() + if infoErr != nil { + atomic.AddInt64(h.progress.Skipped, 1) + if h.progress.Protect != nil { + h.progress.Protect(path) + } + return nil + } + h.progress.CurrentPath.Store(path) + atomic.AddInt64(h.progress.Scanned, 1) + h.progress.Emit() + return h.emit(db.NewEntryFromPath(h.root, path, info.Size(), info.ModTime(), d.IsDir())) +} +func skipWalkEntry(path string, isDir bool, progress scanProgress) error { + if isDir { + return fastwalk.SkipDir + } + progress.CurrentPath.Store(path) + atomic.AddInt64(progress.Skipped, 1) + progress.Emit() + return nil +} + +func newExcludeMatcher(root string, patterns []string) func(path string, isDir bool) bool { + normalized := normalizeExcludePatterns(patterns) root = filepath.Clean(root) return func(path string, _ bool) bool { @@ -458,23 +526,7 @@ func newExcludeMatcher(root string, patterns []string) func(path string, isDir b rel = filepath.Clean(rel) for _, pattern := range normalized { - if strings.Contains(pattern, string(filepath.Separator)) { - if strings.HasSuffix(pattern, string(filepath.Separator)+"*") { - prefix := strings.TrimSuffix(pattern, string(filepath.Separator)+"*") - if rel == prefix || strings.HasPrefix(rel, prefix+string(filepath.Separator)) { - return true - } - } - if ok, _ := filepath.Match(pattern, rel); ok { - return true - } - continue - } - - if filepath.Base(path) == pattern { - return true - } - if ok, _ := filepath.Match(pattern, filepath.Base(path)); ok { + if excludePatternMatches(path, rel, pattern) { return true } } @@ -482,3 +534,32 @@ func newExcludeMatcher(root string, patterns []string) func(path string, isDir b return false } } + +func normalizeExcludePatterns(patterns []string) []string { + normalized := make([]string, 0, len(patterns)) + for _, pattern := range patterns { + if trimmed := strings.TrimSpace(pattern); trimmed != "" { + normalized = append(normalized, filepath.Clean(trimmed)) + } + } + return normalized +} + +func excludePatternMatches(path, rel, pattern string) bool { + if strings.Contains(pattern, string(filepath.Separator)) { + if strings.HasSuffix(pattern, string(filepath.Separator)+"*") { + prefix := strings.TrimSuffix(pattern, string(filepath.Separator)+"*") + if rel == prefix || strings.HasPrefix(rel, prefix+string(filepath.Separator)) { + return true + } + } + matched, _ := filepath.Match(pattern, rel) + return matched + } + base := filepath.Base(path) + if base == pattern { + return true + } + matched, _ := filepath.Match(pattern, base) + return matched +} diff --git a/internal/tui/location_picker.go b/internal/tui/location_picker.go index 6bbfda9..d889c8b 100644 --- a/internal/tui/location_picker.go +++ b/internal/tui/location_picker.go @@ -61,54 +61,73 @@ func (m model) selectLocationRoot(index int) (model, tea.Cmd) { func (m model) updateLocation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { inputEmpty := strings.TrimSpace(m.locationInput.Value()) == "" + if updated, cmd, handled := m.handleLocationNavigation(msg.String(), inputEmpty); handled { + return updated, cmd + } + return m.updateLocationInput(msg) +} - switch msg.String() { +func (m model) handleLocationNavigation(key string, inputEmpty bool) (model, tea.Cmd, bool) { + switch key { case "esc": m.locationInput.Blur() m.err = nil - if m.locationScanLabel == "manual-scan" { - return m.focusSearchView(), nil + if m.locationScanLabel == manualScanLabel { + return m.focusSearchView(), nil, true } - return m, tea.Quit + return m, tea.Quit, true case "up": - if inputEmpty { - m.locationRootCursor = max(0, m.locationRootCursor-1) - } else if len(m.locationSuggestions) > 0 { - m.locationSuggestionActive = true - if m.locationSuggestionCursor < 0 { - m.locationSuggestionCursor = 0 - } else { - m.locationSuggestionCursor = max(0, m.locationSuggestionCursor-1) - } - } - return m, nil case "down": - if inputEmpty { - m.locationRootCursor = min(max(0, len(m.locationRoots)-1), m.locationRootCursor+1) - } else if len(m.locationSuggestions) > 0 { - m.locationSuggestionActive = true - m.locationSuggestionCursor = min(len(m.locationSuggestions)-1, max(0, m.locationSuggestionCursor+1)) - } - return m, nil + return m.moveLocationCursor(key, inputEmpty), nil, true case "tab", "right": if !inputEmpty && len(m.locationSuggestions) > 0 { index := m.locationSuggestionCursor if index < 0 { index = 0 } - return m.acceptLocationSuggestion(index) + updated, cmd := m.acceptLocationSuggestion(index) + return updated, cmd, true } - return m, nil + return m, nil, true case "enter": if inputEmpty { - return m.selectLocationRoot(m.locationRootCursor) + updated, cmd := m.selectLocationRoot(m.locationRootCursor) + return updated, cmd, true } if m.locationSuggestionActive && m.locationSuggestionCursor >= 0 && m.locationSuggestionCursor < len(m.locationSuggestions) { - return m.confirmLocationPath(m.locationSuggestions[m.locationSuggestionCursor]) + updated, cmd := m.confirmLocationPath(m.locationSuggestions[m.locationSuggestionCursor]) + return updated, cmd, true } - return m.confirmLocation() + updated, cmd := m.confirmLocation() + return updated, cmd, true + } + return m, nil, false +} + +func (m model) moveLocationCursor(key string, inputEmpty bool) model { + if inputEmpty { + if key == "up" { + m.locationRootCursor = max(0, m.locationRootCursor-1) + } else { + m.locationRootCursor = min(max(0, len(m.locationRoots)-1), m.locationRootCursor+1) + } + return m + } + if len(m.locationSuggestions) == 0 { + return m + } + m.locationSuggestionActive = true + if key == "up" && m.locationSuggestionCursor < 0 { + m.locationSuggestionCursor = 0 + } else if key == "up" { + m.locationSuggestionCursor = max(0, m.locationSuggestionCursor-1) + } else { + m.locationSuggestionCursor = min(len(m.locationSuggestions)-1, max(0, m.locationSuggestionCursor+1)) } + return m +} +func (m model) updateLocationInput(msg tea.KeyMsg) (model, tea.Cmd) { var cmd tea.Cmd m.locationInput, cmd = m.locationInput.Update(msg) m.locationInput = cleanMouseSequences(m.locationInput) @@ -162,7 +181,7 @@ func (m model) confirmLocationPath(value string) (model, tea.Cmd) { m.activeScanRoot = root m.err = nil m.startupScanAttempted = true - if m.locationScanLabel == "manual-scan" { + if m.locationScanLabel == manualScanLabel { m.status = "manual scan in progress…" } else { m.status = "initial scan in progress…" @@ -190,42 +209,7 @@ func (m model) viewLocation(width, height int) string { "", m.locationInputView(width), } - - if strings.TrimSpace(m.locationInput.Value()) == "" { - lines = append(lines, "", m.theme.Title.Render("QUICK LOCATIONS")) - start, end := locationVisibleRange(len(m.locationRoots), m.locationRootCursor, max(1, height-10)) - for i := start; i < end; i++ { - root := m.locationRoots[i] - prefix := " " - style := m.theme.Text - if i == m.locationRootCursor { - prefix = "➜ " - style = lipgloss.NewStyle().Foreground(m.theme.SelectFG).Background(m.theme.SelectBG).Bold(true) - } else if m.mouseHoverMatches(mouseTargetLocationRoot, i) { - style = lipgloss.NewStyle().Foreground(m.theme.SelectFG).Background(m.theme.SurfaceBG) - } - lines = append(lines, style.Render(prefix+locationRootLabel(root))) - } - } else { - lines = append(lines, "", m.theme.Title.Render("FOLDERS")) - if len(m.locationSuggestions) == 0 { - lines = append(lines, m.theme.Muted.Render("No accessible folders match this path.")) - } else { - start, end := locationVisibleRange(len(m.locationSuggestions), m.locationSuggestionCursor, max(1, height-10)) - for i := start; i < end; i++ { - suggestion := m.locationSuggestions[i] - prefix := " " - style := m.theme.Text - if m.locationSuggestionActive && i == m.locationSuggestionCursor { - prefix = "➜ " - style = lipgloss.NewStyle().Foreground(m.theme.SelectFG).Background(m.theme.SelectBG).Bold(true) - } else if m.mouseHoverMatches(mouseTargetLocationSuggestion, i) { - style = lipgloss.NewStyle().Foreground(m.theme.SelectFG).Background(m.theme.SurfaceBG) - } - lines = append(lines, style.Render(prefix+trimMiddle(suggestion, max(16, min(96, width-16))))) - } - } - } + lines = append(lines, m.locationListLines(width, height)...) if m.err != nil { lines = append(lines, "", m.theme.Err.Render(trimMiddle("error: "+m.err.Error(), max(20, width-12)))) @@ -239,6 +223,52 @@ func (m model) viewLocation(width, height int) string { return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, card) } +func (m model) locationListLines(width, height int) []string { + if strings.TrimSpace(m.locationInput.Value()) == "" { + return m.quickLocationLines(height) + } + return m.locationSuggestionLines(width, height) +} + +func (m model) quickLocationLines(height int) []string { + lines := []string{"", m.theme.Title.Render("QUICK LOCATIONS")} + start, end := locationVisibleRange(len(m.locationRoots), m.locationRootCursor, max(1, height-10)) + for i := start; i < end; i++ { + root := m.locationRoots[i] + prefix := " " + style := m.theme.Text + if i == m.locationRootCursor { + prefix = "➜ " + style = lipgloss.NewStyle().Foreground(m.theme.SelectFG).Background(m.theme.SelectBG).Bold(true) + } else if m.mouseHoverMatches(mouseTargetLocationRoot, i) { + style = lipgloss.NewStyle().Foreground(m.theme.SelectFG).Background(m.theme.SurfaceBG) + } + lines = append(lines, style.Render(prefix+locationRootLabel(root))) + } + return lines +} + +func (m model) locationSuggestionLines(width, height int) []string { + lines := []string{"", m.theme.Title.Render("FOLDERS")} + if len(m.locationSuggestions) == 0 { + return append(lines, m.theme.Muted.Render("No accessible folders match this path.")) + } + start, end := locationVisibleRange(len(m.locationSuggestions), m.locationSuggestionCursor, max(1, height-10)) + for i := start; i < end; i++ { + suggestion := m.locationSuggestions[i] + prefix := " " + style := m.theme.Text + if m.locationSuggestionActive && i == m.locationSuggestionCursor { + prefix = "➜ " + style = lipgloss.NewStyle().Foreground(m.theme.SelectFG).Background(m.theme.SelectBG).Bold(true) + } else if m.mouseHoverMatches(mouseTargetLocationSuggestion, i) { + style = lipgloss.NewStyle().Foreground(m.theme.SelectFG).Background(m.theme.SurfaceBG) + } + lines = append(lines, style.Render(prefix+trimMiddle(suggestion, max(16, min(96, width-16))))) + } + return lines +} + func (m model) locationInputView(width int) string { cardW := max(36, min(100, width-4)) contentW := max(20, cardW-4) diff --git a/internal/tui/open_linux_test.go b/internal/tui/open_linux_test.go index dae188d..7dedc61 100644 --- a/internal/tui/open_linux_test.go +++ b/internal/tui/open_linux_test.go @@ -3,14 +3,20 @@ package tui import ( - "strings" + "os" + "path/filepath" "testing" ) -func TestStartOpenCommandReportsMissingDesktopOpener(t *testing.T) { - t.Setenv("PATH", "") - err := startOpenCommand("/tmp/file.txt", false) - if err == nil || !strings.Contains(err.Error(), "no desktop opener") { - t.Fatalf("expected missing opener error, got %v", err) +func TestFixedExecutableRequiresAbsoluteExecutablePath(t *testing.T) { + path := filepath.Join(t.TempDir(), "opener") + if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatalf("write executable: %v", err) + } + if got := fixedExecutable("opener"); got != "" { + t.Fatalf("relative executable unexpectedly resolved to %q", got) + } + if got := fixedExecutable(path); got != path { + t.Fatalf("absolute executable: want %q, got %q", path, got) } } diff --git a/internal/tui/trash_darwin.go b/internal/tui/trash_darwin.go index 94fb464..de2289e 100644 --- a/internal/tui/trash_darwin.go +++ b/internal/tui/trash_darwin.go @@ -3,13 +3,19 @@ package tui import ( + "errors" + "fmt" "os/exec" "strings" ) func moveToTrash(path string) error { script := `tell application "Finder" to delete POSIX file ` + appleScriptQuote(path) - return exec.Command("osascript", "-e", script).Run() + osascript := fixedExecutable("/usr/bin/osascript") + if osascript == "" { + return fmt.Errorf("move to trash: %w", errors.New("/usr/bin/osascript is unavailable")) + } + return exec.Command(osascript, "-e", script).Run() } func appleScriptQuote(value string) string { diff --git a/internal/tui/trash_unix.go b/internal/tui/trash_unix.go index 88cd88c..0911a6b 100644 --- a/internal/tui/trash_unix.go +++ b/internal/tui/trash_unix.go @@ -3,15 +3,22 @@ package tui import ( + "errors" "fmt" "os/exec" ) func moveToTrash(path string) error { - if err := exec.Command("gio", "trash", "--", path).Run(); err == nil { - return nil + if gio := fixedCommandPath(gioName); gio != "" { + if err := exec.Command(gio, "trash", "--", path).Run(); err == nil { + return nil + } } - if err := exec.Command("trash-put", path).Run(); err != nil { + trashPut := fixedCommandPath("trash-put") + if trashPut == "" { + return fmt.Errorf("move to trash (install gio or trash-cli): %w", errors.New("no fixed trash command found")) + } + if err := exec.Command(trashPut, path).Run(); err != nil { return fmt.Errorf("move to trash (install gio or trash-cli): %w", err) } return nil diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 861de90..5eb04dd 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -43,6 +43,21 @@ const ( deleteConfirmModal ) +const ( + initialScanLabel = "initial-scan" + manualScanLabel = "manual-scan" + unixBinPrefix = "/usr/bin/" + windowsExplorer = `C:\Windows\System32\explorer.exe` + xdgOpenName = "xdg-open" + gioName = "gio" + kdeOpen5Name = "kde-open5" + kdeOpenName = "kde-open" + groovboxAccent = "#83a598" + groovboxSurface = "#3c3836" + catppuccinAccent = "#89dceb" + catppuccinSurface = "#313244" +) + type searchDoneMsg struct { query string results []db.Entry @@ -175,7 +190,7 @@ func (s *scanProgressSource) snapshot(session int) (scanner.Progress, bool) { } type model struct { - ctx context.Context + commandContext func() context.Context cfg config.Config saveConfig func(config.Config) error @@ -242,7 +257,7 @@ type model struct { func Run(ctx context.Context, cfg config.Config) error { m := newModel(ctx, cfg) - p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseAllMotion()) + p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseAllMotion(), tea.WithContext(ctx)) _, err := p.Run() return err } @@ -265,18 +280,18 @@ func newModel(ctx context.Context, cfg config.Config) model { cfgInput.Width = 60 m := model{ - ctx: ctx, - cfg: cfg, - saveConfig: config.Save, - width: 120, - height: 36, - mode: viewLocation, - modal: noModal, - searchInput: searchInput, - cfgInput: cfgInput, - theme: themeByName(cfg.Theme), - themes: []string{"tokyonight", "catppuccin", "groovbox"}, - status: "choose a location to scan", + commandContext: func() context.Context { return ctx }, + cfg: cfg, + saveConfig: config.Save, + width: 120, + height: 36, + mode: viewLocation, + modal: noModal, + searchInput: searchInput, + cfgInput: cfgInput, + theme: themeByName(cfg.Theme), + themes: []string{"tokyonight", "catppuccin", "groovbox"}, + status: "choose a location to scan", scanProgressSource: newScanProgressSource(), } @@ -287,12 +302,19 @@ func newModel(ctx context.Context, cfg config.Config) model { m.locationInput = locationInput m.searchTable = newSearchTable(m.theme) m = m.resizeComponents() - m = m.openLocationPickerView("initial-scan") + m = m.openLocationPickerView(initialScanLabel) return m } +func (m model) context() context.Context { + if m.commandContext == nil { + return context.Background() + } + return m.commandContext() +} + func (m model) Init() tea.Cmd { - return tea.Batch(countCmd(m.ctx, m.cfg.DBPath), textinput.Blink) + return tea.Batch(countCmd(m.context(), m.cfg.DBPath), textinput.Blink) } func newSearchTable(th theme) table.Model { @@ -624,67 +646,85 @@ func (m model) modalInputHitbox() hitbox { func (m model) resolveMouseTarget(x, y int) mouseTarget { if m.modal != noModal { - switch m.modal { - case noModal: - case themeModal: - for i := range m.themes { - if m.themeOptionHitbox(i).contains(x, y) { - return mouseTarget{kind: mouseTargetThemeOption, index: i} - } - } - case excludeInputModal: - if m.modalInputHitbox().contains(x, y) { - return mouseTarget{kind: mouseTargetModalInput} - } - case deleteConfirmModal: - } - if !m.modalBoxHitbox().contains(x, y) { - return mouseTarget{kind: mouseTargetModalOutside} - } - return mouseTarget{kind: mouseTargetNone} + return m.resolveModalMouseTarget(x, y) } switch m.mode { case viewLocation: - if m.locationInputHitbox().contains(x, y) { - return mouseTarget{kind: mouseTargetLocationInput} - } - if strings.TrimSpace(m.locationInput.Value()) == "" { - for i := range m.locationRoots { - if m.locationRootHitbox(i).contains(x, y) { - return mouseTarget{kind: mouseTargetLocationRoot, index: i} - } - } - } else { - for i := range m.locationSuggestions { - if m.locationSuggestionHitbox(i).contains(x, y) { - return mouseTarget{kind: mouseTargetLocationSuggestion, index: i} - } - } - } + return m.resolveLocationMouseTarget(x, y) case viewSearch: - if m.settingsButtonHitbox().contains(x, y) { - return mouseTarget{kind: mouseTargetSettings} + return m.resolveSearchMouseTarget(x, y) + case viewConfig: + return m.resolveConfigMouseTarget(x, y) + } + return mouseTarget{kind: mouseTargetNone} +} + +func (m model) resolveModalMouseTarget(x, y int) mouseTarget { + switch m.modal { + case themeModal: + for i := range m.themes { + if m.themeOptionHitbox(i).contains(x, y) { + return mouseTarget{kind: mouseTargetThemeOption, index: i} + } } - if m.searchInputHitbox().contains(x, y) { - return mouseTarget{kind: mouseTargetSearchInput} + case excludeInputModal: + if m.modalInputHitbox().contains(x, y) { + return mouseTarget{kind: mouseTargetModalInput} } - start, end := m.searchVisibleRange() - for i := start; i < end; i++ { - if m.searchResultHitbox(i).contains(x, y) { - return mouseTarget{kind: mouseTargetSearchResult, index: i} + case deleteConfirmModal: + } + if !m.modalBoxHitbox().contains(x, y) { + return mouseTarget{kind: mouseTargetModalOutside} + } + return mouseTarget{kind: mouseTargetNone} +} + +func (m model) resolveLocationMouseTarget(x, y int) mouseTarget { + if m.locationInputHitbox().contains(x, y) { + return mouseTarget{kind: mouseTargetLocationInput} + } + if strings.TrimSpace(m.locationInput.Value()) == "" { + for i := range m.locationRoots { + if m.locationRootHitbox(i).contains(x, y) { + return mouseTarget{kind: mouseTargetLocationRoot, index: i} } } - case viewConfig: - for i := 0; i < 3; i++ { - if m.configRowHitbox(i).contains(x, y) { - return mouseTarget{kind: mouseTargetConfigRow, index: i} - } + return mouseTarget{kind: mouseTargetNone} + } + for i := range m.locationSuggestions { + if m.locationSuggestionHitbox(i).contains(x, y) { + return mouseTarget{kind: mouseTargetLocationSuggestion, index: i} } - for i := range m.cfg.Excludes { - if m.configExcludeHitbox(i).contains(x, y) { - return mouseTarget{kind: mouseTargetConfigExclude, index: i} - } + } + return mouseTarget{kind: mouseTargetNone} +} + +func (m model) resolveSearchMouseTarget(x, y int) mouseTarget { + if m.settingsButtonHitbox().contains(x, y) { + return mouseTarget{kind: mouseTargetSettings} + } + if m.searchInputHitbox().contains(x, y) { + return mouseTarget{kind: mouseTargetSearchInput} + } + start, end := m.searchVisibleRange() + for i := start; i < end; i++ { + if m.searchResultHitbox(i).contains(x, y) { + return mouseTarget{kind: mouseTargetSearchResult, index: i} + } + } + return mouseTarget{kind: mouseTargetNone} +} + +func (m model) resolveConfigMouseTarget(x, y int) mouseTarget { + for i := 0; i < 3; i++ { + if m.configRowHitbox(i).contains(x, y) { + return mouseTarget{kind: mouseTargetConfigRow, index: i} + } + } + for i := range m.cfg.Excludes { + if m.configExcludeHitbox(i).contains(x, y) { + return mouseTarget{kind: mouseTargetConfigExclude, index: i} } } return mouseTarget{kind: mouseTargetNone} @@ -823,42 +863,11 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { target := m.resolveMouseTarget(ev.X, ev.Y) if ev.Action == tea.MouseActionMotion { - m.hoveredMouse = target - if ev.Button == tea.MouseButtonLeft && target.kind == mouseTargetSearchResult { - m = m.selectSearchResult(target.index) - } - return m, nil + return m.handleMouseMotion(ev, target) } if ev.IsWheel() { - delta := 0 - switch ev.Button { - case tea.MouseButtonWheelUp: - delta = -1 - case tea.MouseButtonWheelDown: - delta = 1 - case tea.MouseButtonNone, tea.MouseButtonLeft, tea.MouseButtonMiddle, tea.MouseButtonRight, - tea.MouseButtonWheelLeft, tea.MouseButtonWheelRight, tea.MouseButtonBackward, - tea.MouseButtonForward, tea.MouseButton10, tea.MouseButton11: - } - if delta == 0 { - return m, nil - } - if m.modal != noModal { - return m.scrollModal(delta), nil - } - switch m.mode { - case viewLocation: - m = m.scrollLocation(delta) - case viewStartup: - case viewSearch: - m = m.scrollSearchResults(delta) - case viewUsage: - m.usageCur = min(max(0, m.usageCur+delta), max(0, m.usageRowCount()-1)) - case viewConfig: - m = m.scrollConfig(delta) - } - return m, nil + return m.handleMouseWheel(ev) } rightClick := ev.Button == tea.MouseButtonRight && (ev.Action == tea.MouseActionPress || ev.Action == tea.MouseActionRelease) @@ -881,6 +890,49 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { return m, nil } + return m.handleMousePress(ev, target) +} + +func (m model) handleMouseMotion(ev tea.MouseEvent, target mouseTarget) (model, tea.Cmd) { + m.hoveredMouse = target + if ev.Button == tea.MouseButtonLeft && target.kind == mouseTargetSearchResult { + m = m.selectSearchResult(target.index) + } + return m, nil +} + +func (m model) handleMouseWheel(ev tea.MouseEvent) (model, tea.Cmd) { + delta := 0 + switch ev.Button { + case tea.MouseButtonWheelUp: + delta = -1 + case tea.MouseButtonWheelDown: + delta = 1 + case tea.MouseButtonNone, tea.MouseButtonLeft, tea.MouseButtonMiddle, tea.MouseButtonRight, + tea.MouseButtonWheelLeft, tea.MouseButtonWheelRight, tea.MouseButtonBackward, + tea.MouseButtonForward, tea.MouseButton10, tea.MouseButton11: + } + if delta == 0 { + return m, nil + } + if m.modal != noModal { + return m.scrollModal(delta), nil + } + switch m.mode { + case viewLocation: + m = m.scrollLocation(delta) + case viewSearch: + m = m.scrollSearchResults(delta) + case viewUsage: + m.usageCur = min(max(0, m.usageCur+delta), max(0, m.usageRowCount()-1)) + case viewConfig: + m = m.scrollConfig(delta) + case viewStartup: + } + return m, nil +} + +func (m model) handleMousePress(ev tea.MouseEvent, target mouseTarget) (model, tea.Cmd) { m.pressedMouse = target m.dragOrigin = target m.hoveredMouse = target @@ -931,200 +983,240 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - m = m.resizeComponents() - return m, nil + m.width, m.height = msg.Width, msg.Height + return m.resizeComponents(), nil + case tea.MouseMsg: + return m.handleMouse(msg) + case tea.KeyMsg: + return m.handleKey(msg) + default: + return m.handleAsync(msg) + } +} +func (m model) handleAsync(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { case countDoneMsg: - if msg.err == nil { - m.totalIndexed = msg.total - } else { - m.err = msg.err - } - return m, nil - + return m.handleCountDone(msg) case usageDoneMsg: - if msg.seq != m.usageSeq || msg.root != m.usageRoot { - return m, nil - } - m.usageBusy = false - m.usageTotal = msg.total - m.usageItems = msg.items - m.usageCur = min(m.usageCur, max(0, len(m.usageItems)-1)) + return m.handleUsageDone(msg) + case openDoneMsg: + return m.handleOpenDone(msg) + case locationSuggestionsMsg: + return m.handleLocationSuggestions(msg) + case searchDoneMsg: + return m.handleSearchDone(msg) + case reindexDoneMsg: + return m.handleReindexDone(msg) + case scanDoneMsg: + return m.handleScanDone(msg) + case debounceSearchMsg: + return m.handleDebounceSearch(msg) + case scanProgressTickMsg: + return m.handleScanProgress(msg) + case deleteResultDoneMsg: + return m.handleDeleteResultDone(msg) + } + return m, nil +} + +func (m model) handleCountDone(msg countDoneMsg) (model, tea.Cmd) { + if msg.err == nil { + m.totalIndexed = msg.total + } else { m.err = msg.err - return m, nil + } + return m, nil +} - case openDoneMsg: - if msg.err != nil { - m.err = msg.err - m.status = "open failed" - } +func (m model) handleUsageDone(msg usageDoneMsg) (model, tea.Cmd) { + if msg.seq != m.usageSeq || msg.root != m.usageRoot { return m, nil + } + m.usageBusy = false + m.usageTotal = msg.total + m.usageItems = msg.items + m.usageCur = min(m.usageCur, max(0, len(m.usageItems)-1)) + m.err = msg.err + return m, nil +} - case locationSuggestionsMsg: - if msg.seq != m.locationInputSeq || msg.input != m.locationInput.Value() { - return m, nil - } - m.locationSuggestions = msg.suggestions - m.locationSuggestionCursor = -1 - m.locationSuggestionActive = false - if msg.err != nil && strings.TrimSpace(msg.input) != "" { - m.err = msg.err - } else if msg.err == nil { - m.err = nil - } +func (m model) handleOpenDone(msg openDoneMsg) (model, tea.Cmd) { + if msg.err != nil { + m.err = msg.err + m.status = "open failed" + } + return m, nil +} + +func (m model) handleLocationSuggestions(msg locationSuggestionsMsg) (model, tea.Cmd) { + if msg.seq != m.locationInputSeq || msg.input != m.locationInput.Value() { return m, nil + } + m.locationSuggestions = msg.suggestions + m.locationSuggestionCursor = -1 + m.locationSuggestionActive = false + if msg.err != nil && strings.TrimSpace(msg.input) != "" { + m.err = msg.err + } else if msg.err == nil { + m.err = nil + } + return m, nil +} - case searchDoneMsg: - if msg.query == m.searchInput.Value() { - m.searchRes = msg.results - if m.searchCur >= len(m.searchRes) { - m.searchCur = max(0, len(m.searchRes)-1) - } - m = m.syncSearchTableRows() - m.err = msg.err - } +func (m model) handleSearchDone(msg searchDoneMsg) (model, tea.Cmd) { + if msg.query != m.searchInput.Value() { return m, nil + } + m.searchRes = msg.results + if m.searchCur >= len(m.searchRes) { + m.searchCur = max(0, len(m.searchRes)-1) + } + m = m.syncSearchTableRows() + m.err = msg.err + return m, nil +} - case reindexDoneMsg: - m.scanCancel = nil - m.busy = false - m.lastMetrics = msg.metrics - if errors.Is(msg.err, context.Canceled) { - m.err = nil - m.status = "scan canceled" - m = m.openLocationPickerView("manual-scan") - return m, countCmd(m.ctx, m.cfg.DBPath) - } - m.err = msg.err - if msg.err == nil { - m.status = fmt.Sprintf("re-index done: scanned=%d indexed=%d", msg.metrics.Scanned, msg.metrics.Indexed) +func (m model) handleReindexDone(msg reindexDoneMsg) (tea.Model, tea.Cmd) { + m.scanCancel = nil + m.busy = false + m.lastMetrics = msg.metrics + if errors.Is(msg.err, context.Canceled) { + m.err = nil + m.status = "scan canceled" + m = m.openLocationPickerView(manualScanLabel) + return m, countCmd(m.context(), m.cfg.DBPath) + } + m.err = msg.err + if msg.err == nil { + m.status = fmt.Sprintf("re-index done: scanned=%d indexed=%d", msg.metrics.Scanned, msg.metrics.Indexed) + m = m.focusSearchView() + } else { + m.status = "re-index failed" + m = m.openLocationPickerView(manualScanLabel) + } + return m, countCmd(m.context(), m.cfg.DBPath) +} + +func (m model) handleScanDone(msg scanDoneMsg) (tea.Model, tea.Cmd) { + m.scanCancel = nil + m.busy = false + m.lastMetrics = msg.metrics + startup := msg.label == initialScanLabel + manual := msg.label == manualScanLabel + if errors.Is(msg.err, context.Canceled) { + m.err = nil + m.status = "scan canceled" + if startup || manual { + m = m.openLocationPickerView(msg.label) + } + return m, countCmd(m.context(), m.cfg.DBPath) + } + m.err = msg.err + if msg.err == nil { + m.status = fmt.Sprintf("%s done: scanned=%d indexed=%d", msg.label, msg.metrics.Scanned, msg.metrics.Indexed) + if startup || manual { m = m.focusSearchView() - } else { - m.status = "re-index failed" - m = m.openLocationPickerView("manual-scan") } - return m, countCmd(m.ctx, m.cfg.DBPath) - - case scanDoneMsg: - m.scanCancel = nil - m.busy = false - m.lastMetrics = msg.metrics - startup := msg.label == "initial-scan" - manual := msg.label == "manual-scan" - if errors.Is(msg.err, context.Canceled) { - m.err = nil - m.status = "scan canceled" - if startup || manual { - m = m.openLocationPickerView(msg.label) - } - return m, countCmd(m.ctx, m.cfg.DBPath) - } - m.err = msg.err - if msg.err == nil { - m.status = fmt.Sprintf("%s done: scanned=%d indexed=%d", msg.label, msg.metrics.Scanned, msg.metrics.Indexed) - if startup || manual { - m = m.focusSearchView() - } - } else { - m.status = msg.label + " failed" - if startup || manual { - m = m.openLocationPickerView(msg.label) - } + } else { + m.status = msg.label + " failed" + if startup || manual { + m = m.openLocationPickerView(msg.label) } - return m, countCmd(m.ctx, m.cfg.DBPath) + } + return m, countCmd(m.context(), m.cfg.DBPath) +} - case debounceSearchMsg: - if msg.seq != m.searchSeq { - return m, nil - } - if strings.TrimSpace(msg.query) == "" { - m.searchRes = nil - return m, nil - } - if m.cfg.LastSearch != msg.query { - m.cfg.LastSearch = msg.query - if err := m.saveConfig(m.cfg); err != nil { - m.err = err - } +func (m model) handleDebounceSearch(msg debounceSearchMsg) (tea.Model, tea.Cmd) { + if msg.seq != m.searchSeq { + return m, nil + } + if strings.TrimSpace(msg.query) == "" { + m.searchRes = nil + return m, nil + } + if m.cfg.LastSearch != msg.query { + m.cfg.LastSearch = msg.query + if err := m.saveConfig(m.cfg); err != nil { + m.err = err } - return m, searchCmd(m.ctx, m.cfg.DBPath, msg.query, m.activeScanRoot) + } + return m, searchCmd(m.context(), m.cfg.DBPath, msg.query, m.activeScanRoot) +} - case scanProgressTickMsg: - if msg.session != m.scanSession || !m.busy || m.scanProgressSource == nil { - return m, nil - } - if progress, ok := m.scanProgressSource.snapshot(msg.session); ok { - m.scanProgress = progress - } - return m, scanProgressTickCmd(msg.session) +func (m model) handleScanProgress(msg scanProgressTickMsg) (tea.Model, tea.Cmd) { + if msg.session != m.scanSession || !m.busy || m.scanProgressSource == nil { + return m, nil + } + if progress, ok := m.scanProgressSource.snapshot(msg.session); ok { + m.scanProgress = progress + } + return m, scanProgressTickCmd(msg.session) +} - case deleteResultDoneMsg: - m.err = msg.err - if msg.err != nil { - m.status = "delete failed" - return m, nil - } - m = m.removeDeletedResult(msg.entry) - m.totalIndexed = msg.total - m.status = "result deleted" +func (m model) handleDeleteResultDone(msg deleteResultDoneMsg) (tea.Model, tea.Cmd) { + m.err = msg.err + if msg.err != nil { + m.status = "delete failed" return m, nil + } + m = m.removeDeletedResult(msg.entry) + m.totalIndexed = msg.total + m.status = "result deleted" + return m, nil +} - case tea.MouseMsg: - return m.handleMouse(msg) +func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if msg.String() == "ctrl+q" { + return m, tea.Quit + } + if m.modal != noModal { + return m.handleModalKey(msg) + } + if updated, cmd, handled := m.handleGlobalKey(msg); handled { + return updated, cmd + } + switch m.mode { + case viewLocation: + return m.updateLocation(msg) + case viewSearch: + return m.updateSearch(msg) + case viewUsage: + return m.updateUsage(msg) + case viewConfig: + return m.updateConfig(msg) + default: + return m, nil + } +} - case tea.KeyMsg: - switch msg.String() { - case "ctrl+q": - return m, tea.Quit - } - if m.modal != noModal { - return m.handleModalKey(msg) - } - switch msg.String() { - case "ctrl+x": - if m.busy && m.scanCancel != nil { - m.status = "stopping scan..." - m.scanCancel() - return m, nil - } - case "ctrl+g": - if !m.busy { - m = m.openLocationPickerView("manual-scan") - return m, nil - } - case "ctrl+s": - if m.mode == viewSearch { - m = m.openSettingsView() - return m, nil - } - case "ctrl+u": - if m.mode == viewSearch && !m.busy { - return m.openUsageView() - } - case "esc": - if m.mode == viewConfig { - m = m.focusSearchView() - return m, nil - } +func (m model) handleGlobalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd, bool) { + switch msg.String() { + case "ctrl+x": + if m.busy && m.scanCancel != nil { + m.status = "stopping scan..." + m.scanCancel() + return m, nil, true } - - switch m.mode { - case viewLocation: - return m.updateLocation(msg) - case viewSearch: - return m.updateSearch(msg) - case viewUsage: - return m.updateUsage(msg) - case viewConfig: - return m.updateConfig(msg) - case viewStartup: - return m, nil + case "ctrl+g": + if !m.busy { + return m.openLocationPickerView(manualScanLabel), nil, true + } + case "ctrl+s": + if m.mode == viewSearch { + return m.openSettingsView(), nil, true + } + case "ctrl+u": + if m.mode == viewSearch && !m.busy { + updated, cmd := m.openUsageView() + return updated, cmd, true + } + case "esc": + if m.mode == viewConfig { + return m.focusSearchView(), nil, true } } - - return m, nil + return m, nil, false } func (m model) openUsageView() (model, tea.Cmd) { @@ -1145,7 +1237,7 @@ func (m model) openUsageView() (model, tea.Cmd) { m.usageBusy = true m.err = nil m.status = "loading disk usage…" - return m, usageCmd(m.ctx, m.cfg.DBPath, root, m.usageSeq) + return m, usageCmd(m.context(), m.cfg.DBPath, root, m.usageSeq) } func (m model) loadUsage(root string) (model, tea.Cmd) { @@ -1161,7 +1253,7 @@ func (m model) loadUsage(root string) (model, tea.Cmd) { m.usageBusy = true m.err = nil m.status = "loading disk usage…" - return m, usageCmd(m.ctx, m.cfg.DBPath, root, m.usageSeq) + return m, usageCmd(m.context(), m.cfg.DBPath, root, m.usageSeq) } func (m model) updateUsage(msg tea.KeyMsg) (tea.Model, tea.Cmd) { @@ -1375,7 +1467,7 @@ func (m model) handleDeleteConfirmModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) index := m.deleteIndex m = m.closeModal() m.status = "deleting result..." - return m, deleteResultCmd(m.ctx, m.cfg, entry, index) + return m, deleteResultCmd(m.context(), m.cfg, entry, index) case "esc", "n": m.status = "delete canceled" return m.closeModal(), nil @@ -1592,7 +1684,7 @@ func (m model) panelStyle() lipgloss.Style { Padding(0, 1) } -func (m model) itemStyleState(active bool, hovered bool) lipgloss.Style { +func (m model) itemStyleState(active, hovered bool) lipgloss.Style { st := m.panelStyle() if active { st = st.BorderForeground(m.theme.BorderHi).Background(m.theme.SurfaceBG) @@ -1616,7 +1708,7 @@ func (m model) inputFocusStyle() lipgloss.Style { func (m model) viewStartup(width, height int) string { message := "Scanning index…" switch m.activeScanLabel { - case "initial-scan": + case initialScanLabel: message = "Scanning before search opens…" case "reindex": message = "Re-indexing…" @@ -1682,75 +1774,93 @@ func (m model) viewUsage(width, height int) string { panel := m.panelStyle() innerWidth := max(1, width-panel.GetHorizontalFrameSize()) innerHeight := max(1, height-panel.GetVerticalFrameSize()) - root := trimMiddle(m.usageRoot, max(1, innerWidth-len("scope: "))) - lines := []string{ - m.theme.Title.Render("DISK USAGE"), - m.theme.Muted.Render("scope: " + root), - m.theme.Highlight.Render("total: " + formatBytes(m.usageTotal)), - m.theme.Muted.Render(fmt.Sprintf("items: %d", len(m.usageItems))), - "", - } + lines := m.usageHeader(innerWidth) if m.usageBusy { lines = append(lines, m.theme.Muted.Render("Calculating directory sizes…")) } else if len(m.usageItems) == 0 && !m.usageHasParent() { lines = append(lines, m.theme.Muted.Render("No files or subdirectories found in this indexed location.")) } else { lines = append(lines, m.theme.Title.Render("LARGEST ITEMS")) - visibleRows := max(0, innerHeight-len(lines)) - start := 0 - if visibleRows > 0 && m.usageCur >= visibleRows { - start = m.usageCur - visibleRows + 1 - } - end := min(m.usageRowCount(), start+visibleRows) - for row := start; row < end; row++ { - if m.usageHasParent() && row == 0 { - marker := " " - style := m.theme.Text - if m.usageCur == 0 { - marker = "› " - style = lipgloss.NewStyle().Foreground(m.theme.SelectFG).Bold(true) - } - lines = append(lines, style.Render(marker+".. Volver")) - continue - } - itemIndex := row - if m.usageHasParent() { - itemIndex-- - } - entry := m.usageItems[itemIndex] - kind := "F" - if entry.IsDir { - kind = "D" - } - percent := 0.0 - if m.usageTotal > 0 { - percent = float64(entry.Size) / float64(m.usageTotal) * 100 - } - marker := " " - if row == m.usageCur { - marker = "› " - } - fixedWidth := lipgloss.Width(fmt.Sprintf("%s%2d [%s] %10s %6.1f%% ", marker, itemIndex+1, kind, formatBytes(entry.Size), percent)) - nameWidth := max(1, min(30, innerWidth-fixedWidth)) - prefix := fmt.Sprintf("%s%2d [%s] %-*s %10s %6.1f%% ", marker, itemIndex+1, kind, nameWidth, trimMiddle(entry.Name, nameWidth), formatBytes(entry.Size), percent) - barWidth := max(0, innerWidth-lipgloss.Width(prefix)) - filled := 0 - if m.usageItems[0].Size > 0 { - filled = int(float64(barWidth) * float64(entry.Size) / float64(m.usageItems[0].Size)) - } - filled = min(barWidth, max(0, filled)) - bar := m.theme.Highlight.Render(strings.Repeat("█", filled)) + m.theme.Muted.Render(strings.Repeat("░", barWidth-filled)) - line := m.theme.Text.Render(prefix) + bar - if row == m.usageCur { - line = lipgloss.NewStyle().Foreground(m.theme.SelectFG).Bold(true).Render(prefix) + bar - } - lines = append(lines, line) - } + lines = append(lines, m.usageRows(innerWidth, innerHeight-len(lines))...) } content := lipgloss.Place(innerWidth, innerHeight, lipgloss.Left, lipgloss.Top, strings.Join(lines, "\n")) return panel.Render(content) } +func (m model) usageHeader(innerWidth int) []string { + root := trimMiddle(m.usageRoot, max(1, innerWidth-len("scope: "))) + return []string{ + m.theme.Title.Render("DISK USAGE"), + m.theme.Muted.Render("scope: " + root), + m.theme.Highlight.Render("total: " + formatBytes(m.usageTotal)), + m.theme.Muted.Render(fmt.Sprintf("items: %d", len(m.usageItems))), + "", + } +} + +func (m model) usageRows(innerWidth, availableHeight int) []string { + visibleRows := max(0, availableHeight) + start := 0 + if visibleRows > 0 && m.usageCur >= visibleRows { + start = m.usageCur - visibleRows + 1 + } + end := min(m.usageRowCount(), start+visibleRows) + lines := make([]string, 0, max(0, end-start)) + for row := start; row < end; row++ { + if m.usageHasParent() && row == 0 { + lines = append(lines, m.usageParentRow()) + continue + } + lines = append(lines, m.usageItemRow(row, innerWidth)) + } + return lines +} + +func (m model) usageParentRow() string { + marker := " " + style := m.theme.Text + if m.usageCur == 0 { + marker = "› " + style = lipgloss.NewStyle().Foreground(m.theme.SelectFG).Bold(true) + } + return style.Render(marker + ".. Volver") +} + +func (m model) usageItemRow(row, innerWidth int) string { + itemIndex := row + if m.usageHasParent() { + itemIndex-- + } + entry := m.usageItems[itemIndex] + kind := "F" + if entry.IsDir { + kind = "D" + } + percent := 0.0 + if m.usageTotal > 0 { + percent = float64(entry.Size) / float64(m.usageTotal) * 100 + } + marker := " " + if row == m.usageCur { + marker = "› " + } + fixedWidth := lipgloss.Width(fmt.Sprintf("%s%2d [%s] %10s %6.1f%% ", marker, itemIndex+1, kind, formatBytes(entry.Size), percent)) + nameWidth := max(1, min(30, innerWidth-fixedWidth)) + prefix := fmt.Sprintf("%s%2d [%s] %-*s %10s %6.1f%% ", marker, itemIndex+1, kind, nameWidth, trimMiddle(entry.Name, nameWidth), formatBytes(entry.Size), percent) + barWidth := max(0, innerWidth-lipgloss.Width(prefix)) + filled := 0 + if m.usageItems[0].Size > 0 { + filled = int(float64(barWidth) * float64(entry.Size) / float64(m.usageItems[0].Size)) + } + filled = min(barWidth, max(0, filled)) + bar := m.theme.Highlight.Render(strings.Repeat("█", filled)) + m.theme.Muted.Render(strings.Repeat("░", barWidth-filled)) + line := m.theme.Text.Render(prefix) + bar + if row == m.usageCur { + line = lipgloss.NewStyle().Foreground(m.theme.SelectFG).Bold(true).Render(prefix) + bar + } + return line +} + func (m model) renderEmptySearchResults() string { tableW := max(searchColumnsWidth(m.searchTable.Columns()), m.searchTable.Width()) blockH := max(3, m.searchTable.Height()+1) @@ -2136,41 +2246,53 @@ func deleteResultCmd(ctx context.Context, cfg config.Config, entry db.Entry, ind if strings.TrimSpace(entry.Path) == "" { return deleteResultDoneMsg{index: index, entry: entry, err: errors.New("path is required")} } - isDir := entry.IsDir - info, statErr := os.Stat(entry.Path) - switch { - case statErr == nil: - isDir = info.IsDir() - if strings.EqualFold(cfg.DeleteMode, config.DeleteModePermanent) { - if err := os.RemoveAll(entry.Path); err != nil { - return deleteResultDoneMsg{index: index, entry: entry, err: err} - } - } else if err := moveToTrash(entry.Path); err != nil { - return deleteResultDoneMsg{index: index, entry: entry, err: err} - } - case errors.Is(statErr, os.ErrNotExist): - default: - return deleteResultDoneMsg{index: index, entry: entry, err: statErr} + isDir, err := removeResultPath(cfg, entry) + if err != nil { + return deleteResultDoneMsg{index: index, entry: entry, err: err} } entry.IsDir = isDir - - store, err := db.Open(ctx, cfg.DBPath) + total, err := removeResultFromIndex(ctx, cfg, entry) if err != nil { return deleteResultDoneMsg{index: index, entry: entry, err: err} } - defer func() { _ = store.Close() }() + return deleteResultDoneMsg{index: index, entry: entry, total: total} + } +} - if isDir { - err = store.DeleteByPrefixWithDirectorySize(ctx, entry.Path) +func removeResultPath(cfg config.Config, entry db.Entry) (bool, error) { + isDir := entry.IsDir + info, err := os.Stat(entry.Path) + switch { + case err == nil: + isDir = info.IsDir() + if strings.EqualFold(cfg.DeleteMode, config.DeleteModePermanent) { + err = os.RemoveAll(entry.Path) } else { - err = store.DeleteByPathWithDirectorySize(ctx, entry.Path) + err = moveToTrash(entry.Path) } - if err != nil { - return deleteResultDoneMsg{index: index, entry: entry, err: err} - } - total, err := store.Count(ctx) - return deleteResultDoneMsg{index: index, entry: entry, total: total, err: err} + case errors.Is(err, os.ErrNotExist): + return isDir, nil + default: + return isDir, err } + return isDir, err +} + +func removeResultFromIndex(ctx context.Context, cfg config.Config, entry db.Entry) (int64, error) { + store, err := db.Open(ctx, cfg.DBPath) + if err != nil { + return 0, err + } + defer func() { _ = store.Close() }() + if entry.IsDir { + err = store.DeleteByPrefixWithDirectorySize(ctx, entry.Path) + } else { + err = store.DeleteByPathWithDirectorySize(ctx, entry.Path) + } + if err != nil { + return 0, err + } + return store.Count(ctx) } func debounceCmd(seq int, query string) tea.Cmd { @@ -2241,7 +2363,7 @@ func scanRootsCmd(ctx context.Context, cfg config.Config, roots []string, label } func (m model) startScanCmd(roots []string, label string, reindex bool) (model, tea.Cmd) { - ctx, cancel := context.WithCancel(m.ctx) + ctx, cancel := context.WithCancel(m.context()) m.scanCancel = cancel m.busy = true m.activeScanLabel = label @@ -2280,17 +2402,22 @@ func startOpenCommand(path string, reveal bool) error { if reveal { openPath = filepath.Dir(path) } - commands := [][]string{ - {"xdg-open", openPath}, - {"gio", "open", openPath}, - {"kde-open5", openPath}, - {"kde-open", openPath}, - } - for _, args := range commands { - if _, err := exec.LookPath(args[0]); err != nil { + commands := []struct { + name string + args []string + }{ + {name: xdgOpenName}, + {name: gioName, args: []string{"open"}}, + {name: kdeOpen5Name}, + {name: kdeOpenName}, + } + for _, command := range commands { + binary := fixedCommandPath(command.name) + if binary == "" { continue } - if err := exec.Command(args[0], args[1:]...).Start(); err == nil { + args := append(command.args, openPath) + if err := exec.Command(binary, args...).Start(); err == nil { return nil } } @@ -2301,22 +2428,39 @@ func openCommand(path string, reveal bool) *exec.Cmd { switch runtime.GOOS { case "windows": if reveal { - return exec.Command("explorer.exe", "/select,"+path) + return exec.Command(windowsExplorer, "/select,"+path) } - return exec.Command("explorer.exe", path) + return exec.Command(windowsExplorer, path) case "darwin": if reveal { - return exec.Command("open", "-R", path) + return exec.Command("/usr/bin/open", "-R", path) } - return exec.Command("open", path) + return exec.Command("/usr/bin/open", path) default: if reveal { - return exec.Command("xdg-open", filepath.Dir(path)) + return exec.Command(unixBinPrefix+xdgOpenName, filepath.Dir(path)) } - return exec.Command("xdg-open", path) + return exec.Command(unixBinPrefix+xdgOpenName, path) } } +func fixedCommandPath(name string) string { + return fixedExecutable(unixBinPrefix+name, "/bin/"+name, "/usr/local/bin/"+name) +} + +func fixedExecutable(candidates ...string) string { + for _, candidate := range candidates { + if !filepath.IsAbs(candidate) { + continue + } + info, err := os.Stat(candidate) + if err == nil && !info.IsDir() && info.Mode()&0o111 != 0 { + return candidate + } + } + return "" +} + type theme struct { Container lipgloss.Style Header lipgloss.Style @@ -2348,42 +2492,42 @@ func themeByName(name string) theme { Title: lipgloss.NewStyle().Foreground(lipgloss.Color("#b8bb26")).Bold(true), Text: lipgloss.NewStyle().Foreground(lipgloss.Color("#ebdbb2")), Muted: lipgloss.NewStyle().Foreground(lipgloss.Color("#a89984")), - Highlight: lipgloss.NewStyle().Foreground(lipgloss.Color("#83a598")).Bold(true), + Highlight: lipgloss.NewStyle().Foreground(lipgloss.Color(groovboxAccent)).Bold(true), Err: lipgloss.NewStyle().Foreground(lipgloss.Color("#fb4934")).Bold(true), Warn: lipgloss.NewStyle().Foreground(lipgloss.Color("#fe8019")).Bold(true), Border: "#504945", - BorderHi: "#83a598", + BorderHi: groovboxAccent, SurfaceBG: "#282828", Badge: "#665c54", - Input: "#83a598", - InputBG: "#3c3836", + Input: groovboxAccent, + InputBG: groovboxSurface, InputFG: "#ebdbb2", - SelectBG: "#3c3836", + SelectBG: groovboxSurface, SelectFG: "#fbf1c7", BusyFG: "#b8bb26", - BusyBG: "#3c3836", + BusyBG: groovboxSurface, } case "catppuccin": return theme{ Container: lipgloss.NewStyle().Padding(1, 2), Header: lipgloss.NewStyle().Foreground(lipgloss.Color("#f5c2e7")), - Title: lipgloss.NewStyle().Foreground(lipgloss.Color("#89dceb")).Bold(true), + Title: lipgloss.NewStyle().Foreground(lipgloss.Color(catppuccinAccent)).Bold(true), Text: lipgloss.NewStyle().Foreground(lipgloss.Color("#cdd6f4")), Muted: lipgloss.NewStyle().Foreground(lipgloss.Color("#a6adc8")), Highlight: lipgloss.NewStyle().Foreground(lipgloss.Color("#94e2d5")).Bold(true), Err: lipgloss.NewStyle().Foreground(lipgloss.Color("#f38ba8")).Bold(true), Warn: lipgloss.NewStyle().Foreground(lipgloss.Color("#fab387")).Bold(true), Border: "#45475a", - BorderHi: "#89dceb", + BorderHi: catppuccinAccent, SurfaceBG: "#1e1e2e", Badge: "#585b70", - Input: "#89dceb", - InputBG: "#313244", + Input: catppuccinAccent, + InputBG: catppuccinSurface, InputFG: "#f5e0dc", - SelectBG: "#313244", + SelectBG: catppuccinSurface, SelectFG: "#f5e0dc", BusyFG: "#a6e3a1", - BusyBG: "#313244", + BusyBG: catppuccinSurface, } default: return theme{ diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index fef13c3..603daa5 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -1611,15 +1611,15 @@ func TestOpenCommandRevealsPathByPlatform(t *testing.T) { cmd := openCommand(path, true) switch runtime.GOOS { case "windows": - if len(cmd.Args) != 2 || cmd.Args[0] != "explorer.exe" || cmd.Args[1] != "/select,"+path { + if len(cmd.Args) != 2 || cmd.Args[0] != `C:\Windows\System32\explorer.exe` || cmd.Args[1] != "/select,"+path { t.Fatalf("expected explorer reveal command, got %#v", cmd.Args) } case "darwin": - if len(cmd.Args) != 3 || cmd.Args[0] != "open" || cmd.Args[1] != "-R" || cmd.Args[2] != path { + if len(cmd.Args) != 3 || cmd.Args[0] != "/usr/bin/open" || cmd.Args[1] != "-R" || cmd.Args[2] != path { t.Fatalf("expected macOS reveal command, got %#v", cmd.Args) } default: - if len(cmd.Args) != 2 || cmd.Args[0] != "xdg-open" || cmd.Args[1] != "/tmp" { + if len(cmd.Args) != 2 || cmd.Args[0] != "/usr/bin/"+xdgOpenName || cmd.Args[1] != "/tmp" { t.Fatalf("expected xdg-open parent command, got %#v", cmd.Args) } } diff --git a/internal/watcher/run_darwin.go b/internal/watcher/run_darwin.go index 3932570..f2143ab 100644 --- a/internal/watcher/run_darwin.go +++ b/internal/watcher/run_darwin.go @@ -16,14 +16,7 @@ import ( ) func (w *Watcher) Run(ctx context.Context, root string) error { - if w.store == nil { - return errors.New("watcher store is required") - } - if root == "" { - return errors.New("watch root is required") - } - - absRoot, err := filepath.Abs(root) + absRoot, err := validateDarwinWatchRoot(w.store, root) if err != nil { return err } @@ -55,36 +48,47 @@ func (w *Watcher) Run(ctx context.Context, root string) error { if !ok { return errors.New("fsevents stream closed") } - - upserts := make([]db.Entry, 0, len(batch)) - for _, evt := range batch { - path := filepath.Clean(evt.Path) - - if evt.Flags&fsevents.ItemRemoved != 0 { - if evt.Flags&fsevents.ItemIsDir != 0 { - if err := deleteWatchedPrefix(ctx, w.store, path); err != nil { - return err - } - continue - } - if err := deleteWatchedPath(ctx, w.store, path); err != nil { - return err - } - continue - } - - if evt.Flags&(fsevents.ItemCreated|fsevents.ItemRenamed|fsevents.ItemModified) != 0 { - info, statErr := os.Stat(path) - if statErr != nil { - continue - } - upserts = append(upserts, db.NewEntryFromPath(absRoot, path, info.Size(), info.ModTime(), info.IsDir())) - } + if err := w.processDarwinBatch(ctx, absRoot, batch); err != nil { + return err } + } + } +} - if err := upsertWatchedEntries(ctx, w.store, upserts); err != nil { +func validateDarwinWatchRoot(store *db.Store, root string) (string, error) { + if store == nil { + return "", errors.New("watcher store is required") + } + if root == "" { + return "", errors.New("watch root is required") + } + return filepath.Abs(root) +} + +func (w *Watcher) processDarwinBatch(ctx context.Context, root string, batch []fsevents.Event) error { + upserts := make([]db.Entry, 0, len(batch)) + for _, evt := range batch { + path := filepath.Clean(evt.Path) + if evt.Flags&fsevents.ItemRemoved != 0 { + if err := w.removeDarwinPath(ctx, path, evt.Flags&fsevents.ItemIsDir != 0); err != nil { return err } + continue } + if evt.Flags&(fsevents.ItemCreated|fsevents.ItemRenamed|fsevents.ItemModified) == 0 { + continue + } + info, err := os.Stat(path) + if err == nil { + upserts = append(upserts, db.NewEntryFromPath(root, path, info.Size(), info.ModTime(), info.IsDir())) + } + } + return upsertWatchedEntries(ctx, w.store, upserts) +} + +func (w *Watcher) removeDarwinPath(ctx context.Context, path string, isDir bool) error { + if isDir { + return deleteWatchedPrefix(ctx, w.store, path) } + return deleteWatchedPath(ctx, w.store, path) } diff --git a/internal/watcher/run_linux.go b/internal/watcher/run_linux.go index 6baec4b..6cb5049 100644 --- a/internal/watcher/run_linux.go +++ b/internal/watcher/run_linux.go @@ -19,24 +19,10 @@ import ( ) func (w *Watcher) Run(ctx context.Context, root string) error { - if w.store == nil { - return errors.New("watcher store is required") - } - if strings.TrimSpace(root) == "" { - return errors.New("watch root is required") - } - - absRoot, err := filepath.Abs(root) + absRoot, err := validateLinuxWatchRoot(w.store, root) if err != nil { return err } - info, err := os.Stat(absRoot) - if err != nil { - return fmt.Errorf("open watch root %q: %w", absRoot, err) - } - if !info.IsDir() { - return fmt.Errorf("watch root %q is not a directory", absRoot) - } notifier, err := fsnotify.NewWatcher() if err != nil { @@ -48,22 +34,34 @@ func (w *Watcher) Run(ctx context.Context, root string) error { if err := addWatchTree(notifier, watched, absRoot); err != nil { return err } + return w.runLinuxEvents(ctx, notifier, watched, absRoot) +} + +func validateLinuxWatchRoot(store *db.Store, root string) (string, error) { + if store == nil { + return "", errors.New("watcher store is required") + } + if strings.TrimSpace(root) == "" { + return "", errors.New("watch root is required") + } + absRoot, err := filepath.Abs(root) + if err != nil { + return "", err + } + info, err := os.Stat(absRoot) + if err != nil { + return "", fmt.Errorf("open watch root %q: %w", absRoot, err) + } + if !info.IsDir() { + return "", fmt.Errorf("watch root %q is not a directory", absRoot) + } + return absRoot, nil +} + +func (w *Watcher) runLinuxEvents(ctx context.Context, notifier *fsnotify.Watcher, watched map[string]struct{}, root string) error { pending := make(map[string]fsnotify.Op) var debounceTimer *time.Timer var debounceC <-chan time.Time - flush := func() error { - if len(pending) == 0 { - return nil - } - batch := pending - pending = make(map[string]fsnotify.Op) - for path, op := range batch { - if err := w.applyLinuxEvent(ctx, notifier, watched, absRoot, fsnotify.Event{Name: path, Op: op}); err != nil { - return err - } - } - return nil - } for { select { @@ -73,34 +71,16 @@ func (w *Watcher) Run(ctx context.Context, root string) error { if !ok { return errors.New("inotify error channel closed") } - if errors.Is(err, fsnotify.ErrEventOverflow) { - return fmt.Errorf("inotify events were lost: %w\nhint: run ge scan --root %q to reconcile the index", err, absRoot) - } - if isWatchLimitError(err) { - return fmt.Errorf("inotify watch limit reached: %w\nhint: increase fs.inotify.max_user_watches or scan a smaller root", err) - } - return fmt.Errorf("inotify watcher error: %w", err) + return linuxNotifierError(err, root) case event, ok := <-notifier.Events: if !ok { return errors.New("inotify event channel closed") } path := filepath.Clean(event.Name) pending[path] |= event.Op - if debounceTimer == nil { - debounceTimer = time.NewTimer(50 * time.Millisecond) - debounceC = debounceTimer.C - } else { - if !debounceTimer.Stop() { - select { - case <-debounceTimer.C: - default: - } - } - debounceTimer.Reset(50 * time.Millisecond) - debounceC = debounceTimer.C - } + resetDebounceTimer(&debounceTimer, &debounceC) case <-debounceC: - if err := flush(); err != nil { + if err := w.flushLinuxEvents(ctx, notifier, watched, root, pending); err != nil { return err } debounceC = nil @@ -108,6 +88,45 @@ func (w *Watcher) Run(ctx context.Context, root string) error { } } +func linuxNotifierError(err error, root string) error { + if errors.Is(err, fsnotify.ErrEventOverflow) { + return fmt.Errorf("inotify events were lost: %w\nhint: run ge scan --root %q to reconcile the index", err, root) + } + if isWatchLimitError(err) { + return fmt.Errorf("inotify watch limit reached: %w\nhint: increase fs.inotify.max_user_watches or scan a smaller root", err) + } + return fmt.Errorf("inotify watcher error: %w", err) +} + +func resetDebounceTimer(timer **time.Timer, channel *<-chan time.Time) { + if *timer == nil { + *timer = time.NewTimer(50 * time.Millisecond) + *channel = (*timer).C + return + } + if !(*timer).Stop() { + select { + case <-(*timer).C: + default: + } + } + (*timer).Reset(50 * time.Millisecond) + *channel = (*timer).C +} + +func (w *Watcher) flushLinuxEvents(ctx context.Context, notifier *fsnotify.Watcher, watched map[string]struct{}, root string, pending map[string]fsnotify.Op) error { + if len(pending) == 0 { + return nil + } + for path, op := range pending { + if err := w.applyLinuxEvent(ctx, notifier, watched, root, fsnotify.Event{Name: path, Op: op}); err != nil { + return err + } + } + clear(pending) + return nil +} + func addWatchTree(notifier *fsnotify.Watcher, watched map[string]struct{}, root string) error { err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { if walkErr != nil { @@ -141,31 +160,14 @@ func addWatchTree(notifier *fsnotify.Watcher, watched map[string]struct{}, root func (w *Watcher) applyLinuxEvent(ctx context.Context, notifier *fsnotify.Watcher, watched map[string]struct{}, root string, event fsnotify.Event) error { path := filepath.Clean(event.Name) if event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename) { - if _, isDir := watched[path]; isDir { - if err := deleteWatchedPrefix(ctx, w.store, path); err != nil { - return err - } - removeWatchedPrefix(watched, path) - } else if err := deleteWatchedPath(ctx, w.store, path); err != nil { - return err - } - return nil + return w.removeLinuxPath(ctx, watched, path) } if event.Has(fsnotify.Create) { - info, err := os.Stat(path) - if err != nil { - if os.IsNotExist(err) { - return nil - } + created, err := w.handleLinuxCreate(ctx, notifier, watched, path) + if err != nil || created { return err } - if info.IsDir() { - if err := addWatchTree(notifier, watched, path); err != nil { - return err - } - return w.scanChangedDirectory(ctx, path) - } } if event.Has(fsnotify.Write) || event.Has(fsnotify.Chmod) || event.Has(fsnotify.Create) { @@ -174,6 +176,34 @@ func (w *Watcher) applyLinuxEvent(ctx context.Context, notifier *fsnotify.Watche return nil } +func (w *Watcher) removeLinuxPath(ctx context.Context, watched map[string]struct{}, path string) error { + if _, isDir := watched[path]; isDir { + if err := deleteWatchedPrefix(ctx, w.store, path); err != nil { + return err + } + removeWatchedPrefix(watched, path) + return nil + } + return deleteWatchedPath(ctx, w.store, path) +} + +func (w *Watcher) handleLinuxCreate(ctx context.Context, notifier *fsnotify.Watcher, watched map[string]struct{}, path string) (bool, error) { + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return true, nil + } + return true, err + } + if !info.IsDir() { + return false, nil + } + if err := addWatchTree(notifier, watched, path); err != nil { + return true, err + } + return true, w.scanChangedDirectory(ctx, path) +} + func (w *Watcher) scanChangedDirectory(ctx context.Context, path string) error { exclude := w.exclude if len(exclude) == 0 { diff --git a/internal/watcher/run_windows.go b/internal/watcher/run_windows.go index 5ebb30d..660b4f3 100644 --- a/internal/watcher/run_windows.go +++ b/internal/watcher/run_windows.go @@ -93,26 +93,12 @@ func (w *Watcher) Run(ctx context.Context, root string) error { func (w *Watcher) applyWindowsNotifications(ctx context.Context, root string, buf []byte) error { for offset := uint32(0); offset < uint32(len(buf)); { - if int(offset)+12 > len(buf) { + notification, ok := parseWindowsNotification(buf, offset) + if !ok { return nil } - next := *(*uint32)(unsafe.Pointer(&buf[offset])) - action := *(*uint32)(unsafe.Pointer(&buf[offset+4])) - nameLen := *(*uint32)(unsafe.Pointer(&buf[offset+8])) - nameStart := offset + 12 - nameEnd := nameStart + nameLen - if nameEnd > uint32(len(buf)) || nameLen%2 != 0 { - return nil - } - nameBytes := buf[nameStart:nameEnd] - name := "" - if len(nameBytes) > 0 { - u16 := unsafe.Slice((*uint16)(unsafe.Pointer(&nameBytes[0])), len(nameBytes)/2) - name = string(utf16.Decode(u16)) - } - path := filepath.Clean(filepath.Join(root, name)) - - switch action { + path := filepath.Clean(filepath.Join(root, notification.name)) + switch notification.action { case fileActionRemoved, fileActionRenamedOldName: if err := deleteWatchedPathAndDescendants(ctx, w.store, path); err != nil { return err @@ -123,14 +109,42 @@ func (w *Watcher) applyWindowsNotifications(ctx context.Context, root string, bu } } - if next == 0 { + if notification.next == 0 { return nil } - offset += next + offset += notification.next } return nil } +type windowsNotification struct { + next uint32 + action uint32 + name string +} + +func parseWindowsNotification(buf []byte, offset uint32) (windowsNotification, bool) { + if int(offset)+12 > len(buf) { + return windowsNotification{}, false + } + notification := windowsNotification{ + next: *(*uint32)(unsafe.Pointer(&buf[offset])), + action: *(*uint32)(unsafe.Pointer(&buf[offset+4])), + } + nameLen := *(*uint32)(unsafe.Pointer(&buf[offset+8])) + nameStart := offset + 12 + nameEnd := nameStart + nameLen + if nameEnd > uint32(len(buf)) || nameLen%2 != 0 { + return windowsNotification{}, false + } + nameBytes := buf[nameStart:nameEnd] + if len(nameBytes) > 0 { + u16 := unsafe.Slice((*uint16)(unsafe.Pointer(&nameBytes[0])), len(nameBytes)/2) + notification.name = string(utf16.Decode(u16)) + } + return notification, true +} + func upsertChangedPath(ctx context.Context, store *db.Store, root, path string) error { info, err := os.Stat(path) if err != nil { diff --git a/scripts/install.sh b/scripts/install.sh index 652d7aa..555b03a 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -6,7 +6,8 @@ BINARY="ge" PROJECT="goeverything" need_cmd() { - command -v "$1" >/dev/null 2>&1 || { echo "error: $1 is required" >&2; exit 1; } + local command_name="$1" + command -v "$command_name" >/dev/null 2>&1 || { echo "error: $command_name is required" >&2; exit 1; } } need_cmd curl @@ -28,7 +29,7 @@ esac TAG="${1:-}" if [[ -z "$TAG" ]]; then - TAG="$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n1)" + TAG="$(curl --proto '=https' --tlsv1.2 -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n1)" fi if [[ -z "$TAG" ]]; then @@ -44,7 +45,7 @@ TMP_DIR="$(mktemp -d)" trap 'rm -rf "$TMP_DIR"' EXIT echo "Downloading ${URL}..." -curl -fL "$URL" -o "$TMP_DIR/$ASSET" +curl --proto '=https' --tlsv1.2 -fL "$URL" -o "$TMP_DIR/$ASSET" tar -xzf "$TMP_DIR/$ASSET" -C "$TMP_DIR" if [[ ! -f "$TMP_DIR/$BINARY" ]]; then