Skip to content

Commit b44b8f5

Browse files
authored
Merge pull request #23 from epilande/tree-only
feat(output): Add tree-only mode for structure-only output
2 parents 1bae135 + e549467 commit b44b8f5

13 files changed

Lines changed: 227 additions & 12 deletions

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ to your clipboard, ready for LLM processing.
2727
-**Temp File**: Generate the output file in your system's temporary directory
2828
- 📋 **Clipboard Integration**: Copy content or output file directly to your clipboard
2929
- 🌲 **Directory Tree View**: Display a tree-style view of your project structure
30+
- 🌳 **Tree-Only Mode**: Output just the file structure without contents using `-T` flag
3031
- 🧮 **Token Estimation**: Get estimated token count for LLM context windows
3132
- 🛡️ **Secret Detection & Redaction**: Uses [gitleaks](https://github.com/gitleaks/gitleaks) to identify potential secrets and prevent sharing sensitive information
3233
- 🔗 **Dependency Resolution**: Automatically include dependencies for Go, JS/TS, Python when using the `--deps` flag
@@ -108,6 +109,7 @@ grab [options] [directory]
108109
| `--theme <name>` | Set the UI theme. Available: catppuccin-latte, catppuccin-frappe, catppuccin-macchiato, catppuccin-mocha, rose-pine, rose-pine-dawn, rose-pine-moon, dracula, nord. (default: `"catppuccin-mocha"`). |
109110
| `--show-tokens` | Show the number of tokens for each file in file tree. |
110111
| `--icons` | Display Nerd Font icons. |
112+
| `-T, --tree-only` | Output only file structure without contents. Useful for sharing project layout with LLMs. |
111113

112114
### 📖 Examples
113115

@@ -177,6 +179,12 @@ grab [options] [directory]
177179
grab git@github.com:user/repo.git
178180
```
179181

182+
12. Output only the project structure (no file contents):
183+
184+
```bash
185+
grab -T -n
186+
```
187+
180188
## ⌨️ Keyboard Controls
181189

182190
### Navigation
@@ -211,6 +219,7 @@ grab [options] [directory]
211219
| Toggle Dependency Resolution | <kbd>D</kbd> | Enable/disable automatic dependency resolution for Go & JS/TS (Default: Off) |
212220
| Cycle output formats | <kbd>F</kbd> | Cycle through available output formats (markdown, text, xml) |
213221
| Toggle Secret Redaction | <kbd>S</kbd> | Enable/disable automatic secret redaction (Default: On) |
222+
| Toggle Tree-Only Mode | <kbd>T</kbd> | Output only file structure without contents (Default: Off) |
214223

215224
### View Options
216225

cmd/grab/main.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ func main() {
5050
var maxFileSizeStr string
5151
var showIcons bool
5252
var showTokenCount bool
53+
var treeOnly bool
5354

5455
flag.BoolVar(&showHelp, "help", false, "Display help information")
5556
flag.BoolVar(&showHelp, "h", false, "Display help information (shorthand)")
@@ -92,6 +93,9 @@ func main() {
9293

9394
flag.BoolVar(&showTokenCount, "show-tokens", false, "Show the number of tokens for each file")
9495

96+
flag.BoolVar(&treeOnly, "tree-only", false, "Output only file structure without contents")
97+
flag.BoolVar(&treeOnly, "T", false, "Output only file structure (shorthand)")
98+
9599
flag.Parse()
96100

97101
if showHelp {
@@ -187,7 +191,7 @@ func main() {
187191
}
188192

189193
if nonInteractive {
190-
runNonInteractive(root, filterMgr, outputPath, useTempFile, formatName, skipRedaction, resolveDeps, maxDepth, maxFileSize)
194+
runNonInteractive(root, filterMgr, outputPath, useTempFile, formatName, skipRedaction, resolveDeps, maxDepth, maxFileSize, treeOnly)
191195
} else {
192196
config := model.Config{
193197
RootPath: root,
@@ -201,6 +205,7 @@ func main() {
201205
ShowTokenCount: showTokenCount,
202206
MaxDepth: maxDepth,
203207
MaxFileSize: maxFileSize,
208+
TreeOnly: treeOnly,
204209
}
205210

206211
m := model.NewModel(config)
@@ -213,7 +218,7 @@ func main() {
213218
}
214219

215220
// runNonInteractive processes files and generates output without user interaction
216-
func runNonInteractive(rootPath string, filterMgr *filesystem.FilterManager, outputPath string, useTempFile bool, formatName string, skipRedaction bool, resolveDeps bool, maxDepth int, maxFileSize int64) {
221+
func runNonInteractive(rootPath string, filterMgr *filesystem.FilterManager, outputPath string, useTempFile bool, formatName string, skipRedaction bool, resolveDeps bool, maxDepth int, maxFileSize int64, treeOnly bool) {
217222
gitIgnoreMgr, err := filesystem.NewGitIgnoreManager(rootPath)
218223
if err != nil {
219224
log.Fatalf("Error reading .gitignore: %v\n", err)
@@ -301,6 +306,7 @@ func runNonInteractive(rootPath string, filterMgr *filesystem.FilterManager, out
301306
format := formats.GetFormat(formatName)
302307
gen.SetFormat(format)
303308
gen.SetRedactionMode(!skipRedaction)
309+
gen.SetTreeOnlyMode(treeOnly)
304310

305311
gen.SelectedFiles = selectedFiles
306312

internal/generator/format.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ type FileData struct {
1414
type TemplateData struct {
1515
Structure string
1616
Files []FileData
17+
FilePaths []string
1718
}
1819

1920
// Format defines the interface for different output formats

internal/generator/formats/formats_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,95 @@ func createTestTemplateData() generator.TemplateData {
153153
Language: "go",
154154
},
155155
},
156+
FilePaths: []string{"main.go"},
157+
}
158+
}
159+
160+
func createTreeOnlyTemplateData() generator.TemplateData {
161+
return generator.TemplateData{
162+
Structure: "test-project/\n├── src/\n│ └── main.go\n└── README.md\n",
163+
Files: []generator.FileData{},
164+
FilePaths: []string{"src/main.go", "README.md"},
165+
}
166+
}
167+
168+
func TestMarkdownFormatTreeOnly(t *testing.T) {
169+
format := &MarkdownFormat{}
170+
data := createTreeOnlyTemplateData()
171+
172+
content, tokens, err := format.Render(data)
173+
if err != nil {
174+
t.Fatalf("Render failed: %v", err)
175+
}
176+
177+
if !strings.Contains(content, "# Project Structure") {
178+
t.Errorf("Expected content to contain '# Project Structure'")
179+
}
180+
if !strings.Contains(content, "test-project/") {
181+
t.Errorf("Expected content to contain 'test-project/'")
182+
}
183+
if strings.Contains(content, "# Project Files") {
184+
t.Errorf("Expected content to NOT contain '# Project Files' in tree-only mode")
185+
}
186+
if tokens <= 0 {
187+
t.Errorf("Expected tokens to be positive, got %d", tokens)
188+
}
189+
}
190+
191+
func TestTxtFormatTreeOnly(t *testing.T) {
192+
format := &TxtFormat{}
193+
data := createTreeOnlyTemplateData()
194+
195+
content, tokens, err := format.Render(data)
196+
if err != nil {
197+
t.Fatalf("Render failed: %v", err)
198+
}
199+
200+
if !strings.Contains(content, "PROJECT STRUCTURE") {
201+
t.Errorf("Expected content to contain 'PROJECT STRUCTURE'")
202+
}
203+
if !strings.Contains(content, "test-project/") {
204+
t.Errorf("Expected content to contain 'test-project/'")
205+
}
206+
if strings.Contains(content, "PROJECT FILES") {
207+
t.Errorf("Expected content to NOT contain 'PROJECT FILES' in tree-only mode")
208+
}
209+
if tokens <= 0 {
210+
t.Errorf("Expected tokens to be positive, got %d", tokens)
211+
}
212+
}
213+
214+
func TestXMLFormatTreeOnly(t *testing.T) {
215+
format := &XMLFormat{}
216+
data := createTreeOnlyTemplateData()
217+
218+
content, tokens, err := format.Render(data)
219+
if err != nil {
220+
t.Fatalf("Render failed: %v", err)
221+
}
222+
223+
if !strings.Contains(content, "<?xml") {
224+
t.Errorf("Expected content to contain '<?xml'")
225+
}
226+
if !strings.Contains(content, "<project>") {
227+
t.Errorf("Expected content to contain '<project>'")
228+
}
229+
// Directory structure should be built from FilePaths
230+
if !strings.Contains(content, "<directory name=\"src\">") {
231+
t.Errorf("Expected content to contain '<directory name=\"src\">' from FilePaths")
232+
}
233+
if !strings.Contains(content, "<file name=\"main.go\">") {
234+
t.Errorf("Expected content to contain '<file name=\"main.go\">' in filesystem section")
235+
}
236+
if !strings.Contains(content, "<file name=\"README.md\">") {
237+
t.Errorf("Expected content to contain '<file name=\"README.md\">' in filesystem section")
238+
}
239+
// Files section should be empty (no file contents)
240+
if strings.Contains(content, "<file path=") {
241+
t.Errorf("Expected content to NOT contain file content elements in tree-only mode")
242+
}
243+
if tokens <= 0 {
244+
t.Errorf("Expected tokens to be positive, got %d", tokens)
156245
}
157246
}
158247

internal/generator/formats/markdown.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,12 @@ const markdownTemplate = `# Project Structure
4444
4545
` + "```" + `
4646
{{.Structure}}` + "```" + `
47-
47+
{{if .Files}}
4848
# Project Files
4949
{{range .Files}}
5050
## File: ` + "`" + `{{.Path}}` + "`" + `
5151
5252
` + "```" + `{{.Language}}
5353
{{.Content}}
5454
` + "```" + `
55-
{{end}}
56-
`
55+
{{end}}{{end}}`

internal/generator/formats/text.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ PROJECT STRUCTURE
5757
{{separator}}
5858
5959
{{.Structure}}
60-
60+
{{if .Files}}
6161
{{separator}}
6262
PROJECT FILES
6363
{{separator}}
@@ -67,5 +67,4 @@ FILE: {{.Path}}
6767
{{separator .Path}}
6868
6969
{{.Content}}
70-
{{end}}
71-
`
70+
{{end}}{{end}}`

internal/generator/formats/xml.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,8 @@ func (f *XMLFormat) Render(data generator.TemplateData) (string, int, error) {
6161
files: []string{},
6262
}
6363

64-
// Get all file paths from the files data
65-
for _, file := range data.Files {
66-
addFileToTree(root, file.Path)
64+
for _, path := range data.FilePaths {
65+
addFileToTree(root, path)
6766
}
6867

6968
// Convert our internal tree to the XML structure

internal/generator/generator.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type Generator struct {
2626
UseGitIgnore bool
2727
ShowHidden bool
2828
RedactSecrets bool
29+
TreeOnly bool
2930
lastSecretCount int
3031
}
3132

@@ -75,6 +76,11 @@ func (g *Generator) SetRedactionMode(redact bool) {
7576
g.RedactSecrets = redact
7677
}
7778

79+
// SetTreeOnlyMode enables or disables tree-only output (structure without file contents).
80+
func (g *Generator) SetTreeOnlyMode(treeOnly bool) {
81+
g.TreeOnly = treeOnly
82+
}
83+
7884
// Generate creates an output file in the specified format
7985
func (g *Generator) Generate() (string, int, int, error) {
8086
if len(g.SelectedFiles) == 0 {
@@ -196,6 +202,11 @@ func (g *Generator) PrepareTemplateData() (TemplateData, error) {
196202

197203
g.SelectedFiles = expandedSelection
198204

205+
filePaths := make([]string, 0, len(expandedSelection))
206+
for path := range expandedSelection {
207+
filePaths = append(filePaths, path)
208+
}
209+
199210
rootNode := g.buildTree()
200211
var structureBuilder strings.Builder
201212
baseRootName := filepath.Base(g.RootPath)
@@ -211,7 +222,9 @@ func (g *Generator) PrepareTemplateData() (TemplateData, error) {
211222
}
212223

213224
var filesData []FileData
214-
collectFiles(rootNode, &filesData, g.RootPath, &g.SecretScanner)
225+
if !g.TreeOnly {
226+
collectFiles(rootNode, &filesData, g.RootPath, &g.SecretScanner)
227+
}
215228

216229
secretCount := 0
217230

@@ -231,5 +244,6 @@ func (g *Generator) PrepareTemplateData() (TemplateData, error) {
231244
return TemplateData{
232245
Structure: structureBuilder.String(),
233246
Files: filesData,
247+
FilePaths: filePaths,
234248
}, nil
235249
}

internal/generator/generator_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,11 @@ func TestPrepareTemplateData(t *testing.T) {
151151
t.Errorf("Expected language for %q to be %q, got %q", tf.path, "go", file.Language)
152152
}
153153
}
154+
155+
// Verify FilePaths is also populated
156+
if len(data.FilePaths) != 3 {
157+
t.Errorf("Expected FilePaths to have 3 entries, got %d", len(data.FilePaths))
158+
}
154159
}
155160

156161
func TestGenerateString(t *testing.T) {
@@ -214,6 +219,80 @@ func TestGenerateStringWithNoFormat(t *testing.T) {
214219
}
215220
}
216221

222+
func TestSetTreeOnlyMode(t *testing.T) {
223+
gen := NewGenerator(".", nil, nil, "", false)
224+
225+
if gen.TreeOnly {
226+
t.Errorf("Expected TreeOnly to be false by default")
227+
}
228+
229+
gen.SetTreeOnlyMode(true)
230+
if !gen.TreeOnly {
231+
t.Errorf("Expected TreeOnly to be true after SetTreeOnlyMode(true)")
232+
}
233+
234+
gen.SetTreeOnlyMode(false)
235+
if gen.TreeOnly {
236+
t.Errorf("Expected TreeOnly to be false after SetTreeOnlyMode(false)")
237+
}
238+
}
239+
240+
func TestPrepareTemplateDataTreeOnly(t *testing.T) {
241+
cache.ResetGlobalCache()
242+
243+
tempDir, err := os.MkdirTemp("", "generator-test-treeonly")
244+
if err != nil {
245+
t.Fatalf("Failed to create temp dir: %v", err)
246+
}
247+
defer os.RemoveAll(tempDir)
248+
249+
testFiles := []struct {
250+
path string
251+
content string
252+
}{
253+
{"file1.txt", "Content of file1"},
254+
{"file2.go", "package main\n\nfunc main() {}"},
255+
{"subdir/file3.txt", "Content of file3"},
256+
}
257+
258+
for _, tf := range testFiles {
259+
path := filepath.Join(tempDir, filepath.FromSlash(tf.path))
260+
dir := filepath.Dir(path)
261+
if err := os.MkdirAll(dir, 0755); err != nil {
262+
t.Fatalf("Failed to create directory %s: %v", dir, err)
263+
}
264+
if err := os.WriteFile(path, []byte(tf.content), 0644); err != nil {
265+
t.Fatalf("Failed to create file %s: %v", path, err)
266+
}
267+
}
268+
269+
gitIgnoreMgr, _ := filesystem.NewGitIgnoreManager(tempDir)
270+
filterMgr := filesystem.NewFilterManager()
271+
gen := NewGenerator(tempDir, gitIgnoreMgr, filterMgr, "", false)
272+
gen.SelectedFiles = map[string]bool{
273+
"file1.txt": true,
274+
"file2.go": true,
275+
"subdir/file3.txt": true,
276+
}
277+
gen.SetTreeOnlyMode(true)
278+
279+
data, err := gen.PrepareTemplateData()
280+
if err != nil {
281+
t.Fatalf("PrepareTemplateData failed: %v", err)
282+
}
283+
284+
if data.Structure == "" {
285+
t.Errorf("Expected Structure to be non-empty in tree-only mode")
286+
}
287+
if len(data.Files) != 0 {
288+
t.Errorf("Expected Files to be empty in tree-only mode, got %d files", len(data.Files))
289+
}
290+
// FilePaths should still be populated for XML format support
291+
if len(data.FilePaths) != 3 {
292+
t.Errorf("Expected FilePaths to have 3 entries in tree-only mode, got %d", len(data.FilePaths))
293+
}
294+
}
295+
217296
type mockFormat struct {
218297
err error
219298
name string

internal/model/init_update.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
471471
}
472472
m.warningMsg = ""
473473
m.refreshViewportContent()
474+
case "T":
475+
m.treeOnly = !m.treeOnly
476+
m.generator.SetTreeOnlyMode(m.treeOnly)
477+
if m.treeOnly {
478+
m.successMsg = "Tree-only mode enabled (structure only)"
479+
} else {
480+
m.successMsg = "Tree-only mode disabled (full output)"
481+
}
482+
m.refreshViewportContent()
474483
case "P":
475484
// Toggle preview pane
476485
m.showPreview = !m.showPreview

0 commit comments

Comments
 (0)