summaryrefslogtreecommitdiffhomepage
path: root/packages/opencode/src/tool/ls.ts
blob: f3b044cbc15f5cef45ae8a671ddf0f04902e3482 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
import * as path from "path"
import z from "zod"
import { Effect } from "effect"
import * as Stream from "effect/Stream"
import { InstanceState } from "@/effect/instance-state"
import { Ripgrep } from "../file/ripgrep"
import { assertExternalDirectoryEffect } from "./external-directory"
import DESCRIPTION from "./ls.txt"
import { Tool } from "./tool"

export const IGNORE_PATTERNS = [
  "node_modules/",
  "__pycache__/",
  ".git/",
  "dist/",
  "build/",
  "target/",
  "vendor/",
  "bin/",
  "obj/",
  ".idea/",
  ".vscode/",
  ".zig-cache/",
  "zig-out",
  ".coverage",
  "coverage/",
  "vendor/",
  "tmp/",
  "temp/",
  ".cache/",
  "cache/",
  "logs/",
  ".venv/",
  "venv/",
  "env/",
]

const LIMIT = 100

export const ListTool = Tool.define(
  "list",
  Effect.gen(function* () {
    const rg = yield* Ripgrep.Service

    return {
      description: DESCRIPTION,
      parameters: z.object({
        path: z
          .string()
          .describe("The absolute path to the directory to list (must be absolute, not relative)")
          .optional(),
        ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(),
      }),
      execute: (params: { path?: string; ignore?: string[] }, ctx: Tool.Context) =>
        Effect.gen(function* () {
          const ins = yield* InstanceState.context
          const search = path.resolve(ins.directory, params.path || ".")
          yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" })

          yield* ctx.ask({
            permission: "list",
            patterns: [search],
            always: ["*"],
            metadata: {
              path: search,
            },
          })

          const glob = IGNORE_PATTERNS.map((item) => `!${item}*`).concat(params.ignore?.map((item) => `!${item}`) || [])
          const files = yield* rg.files({ cwd: search, glob, signal: ctx.abort }).pipe(
            Stream.take(LIMIT + 1),
            Stream.runCollect,
            Effect.map((chunk) => [...chunk]),
          )

          const truncated = files.length > LIMIT
          if (truncated) files.length = LIMIT

          const dirs = new Set<string>()
          const map = new Map<string, string[]>()
          for (const file of files) {
            const dir = path.dirname(file)
            const parts = dir === "." ? [] : dir.split("/")
            for (let i = 0; i <= parts.length; i++) {
              dirs.add(i === 0 ? "." : parts.slice(0, i).join("/"))
            }
            if (!map.has(dir)) map.set(dir, [])
            map.get(dir)!.push(path.basename(file))
          }

          function render(dir: string, depth: number): string {
            const indent = "  ".repeat(depth)
            let output = ""
            if (depth > 0) output += `${indent}${path.basename(dir)}/\n`

            const child = "  ".repeat(depth + 1)
            const dirs2 = Array.from(dirs)
              .filter((item) => path.dirname(item) === dir && item !== dir)
              .sort()
            for (const item of dirs2) {
              output += render(item, depth + 1)
            }

            const files = map.get(dir) || []
            for (const file of files.sort()) {
              output += `${child}${file}\n`
            }
            return output
          }

          return {
            title: path.relative(ins.worktree, search),
            metadata: {
              count: files.length,
              truncated,
            },
            output: `${search}/\n` + render(".", 0),
          }
        }).pipe(Effect.orDie),
    }
  }),
)