Skip to content

Commit 0fc0868

Browse files
authored
Merge pull request #14 from abhixdd/feat/ux-cli-and-tests
feat: enhance UX, refine download path logic, and expand test suite
2 parents 651e08e + c7e03b1 commit 0fc0868

10 files changed

Lines changed: 454 additions & 84 deletions

File tree

README.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
- **No more clone-and-delete**: Grab exactly what you need, when you need it.
1414
- **Easy on the eyes**: A clean terminal interface that makes browsing feel smooth.
1515
- **Works where you are**: Installs quickly via NPM, Cargo, or PIP.
16-
- **Find things fast**: Quickly search and navigate through any repo's folders.
16+
- **Find things fast**: Quickly search and navigate through any repo's folders with fuzzy search.
1717
- **Handles the big stuff**: Built-in support for GitHub LFS (Large File Storage).
1818
- **Batch mode**: Select a bunch of files and folders to download them all at once.
1919

@@ -49,9 +49,21 @@ ghgrab
4949
Or, if you already have a link, just paste it in:
5050

5151
```bash
52+
# Browse a repository
5253
ghgrab https://github.com/rust-lang/rust
54+
55+
# Download to current directory directly
56+
ghgrab https://github.com/rust-lang/rust --cwd --no-folder
5357
```
5458

59+
### CLI Flags
60+
61+
| Flag | Description |
62+
|------|-------------|
63+
| `--cwd` | Forces download to the current working directory. |
64+
| `--no-folder` | Downloads files directly without creating a subfolder for the repo. |
65+
| `--token <TOKEN>`| Use a specific GitHub token for this run (doesn't save to settings). |
66+
5567
### Configuration
5668

5769
To manage your settings:
@@ -79,7 +91,10 @@ We've kept it pretty standard, but here's a quick cheat sheet:
7991
|-----|--------|
8092
| `Enter` / `l` / `Right` | Enter directory or Submit URL |
8193
| `Backspace` / `h` / `Left` | Go back to previous folder |
82-
| `Esc` | **Return Home** (file list) or **Quit** (home screen) |
94+
| `Delete` | Clear URL input (Home page) |
95+
| `Tab` | Auto-fill `https://github.com/` (Home page) |
96+
| `/` | Start Searching (File list) |
97+
| `Esc` | **Exit Search** or **Return Home** (file list) or **Quit** (home screen) |
8398
| `q` / `Q` | **Quit** (from file list) |
8499
| `Ctrl+q` | **Force Quit** (anywhere) |
85100
| `Space` | Toggle selection for the current item |

bin/ghgrab.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ const { spawn } = require('child_process');
33
const path = require('path');
44
const fs = require('fs');
55

6-
// Binary lives next to this file in the bin/ directory
76
const binaryName = 'ghgrab' + (process.platform === 'win32' ? '.exe' : '');
87
const binPath = path.join(__dirname, binaryName);
98

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
66
name = "ghgrab"
77
version = "1.0.0"
88
description = "Download specific files and folders from GitHub repositories without cloning"
9-
authors = [{name = "Abhinav A"}]
9+
authors = [{name = "abhixdd"}]
1010
license = {text = "MIT"}
1111
readme = "README.md"
1212
requires-python = ">=3.7"

src/download.rs

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,6 @@ impl Downloader {
2626
let mut errors = Vec::new();
2727

2828
for item in items {
29-
if !item.selected {
30-
continue;
31-
}
32-
3329
let dest_path = self.base_path.join(&item.name);
3430

3531
let result = if item.is_file() {

src/github.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,31 @@ impl GitHubUrl {
5252
})
5353
}
5454

55+
pub fn get_local_git_remote() -> Option<String> {
56+
use std::process::Command;
57+
let output = Command::new("git")
58+
.args(["remote", "get-url", "origin"])
59+
.output()
60+
.ok()?;
61+
62+
if output.status.success() {
63+
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
64+
if !url.is_empty() {
65+
if url.starts_with("git@github.com:") {
66+
let path = url
67+
.trim_start_matches("git@github.com:")
68+
.trim_end_matches(".git");
69+
return Some(format!("https://github.com/{}", path));
70+
}
71+
if url.contains("github.com") && url.ends_with(".git") {
72+
return Some(url.trim_end_matches(".git").to_string());
73+
}
74+
return Some(url);
75+
}
76+
}
77+
None
78+
}
79+
5580
pub fn api_url(&self) -> String {
5681
let base = format!(
5782
"https://api.github.com/repos/{}/{}/contents",

src/main.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use anyhow::Result;
22
use clap::{Parser, Subcommand};
33
use ghgrab::config::Config;
4+
45
use ghgrab::ui;
56

67
#[derive(Parser)]
@@ -10,6 +11,15 @@ struct Cli {
1011
command: Option<Commands>,
1112

1213
url: Option<String>,
14+
15+
#[arg(long, help = "Download files to current directory")]
16+
cwd: bool,
17+
18+
#[arg(long, help = "Download files directly into target without repo folder")]
19+
no_folder: bool,
20+
21+
#[arg(long, help = "One-time GitHub token (not stored)")]
22+
token: Option<String>,
1323
}
1424

1525
#[derive(Subcommand)]
@@ -109,7 +119,13 @@ async fn main() -> Result<()> {
109119
},
110120
None => {
111121
let config = Config::load().unwrap_or_default();
112-
ui::run_tui(cli.url, config.github_token, config.download_path).await?;
122+
123+
let url = cli.url;
124+
125+
let download_path = config.download_path;
126+
127+
let token = cli.token.or(config.github_token);
128+
ui::run_tui(url, token, download_path, cli.cwd, cli.no_folder).await?;
113129
}
114130
}
115131

src/ui/components/browser.rs

Lines changed: 46 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -15,35 +15,33 @@ pub struct BrowserState<'a> {
1515
pub current_url: Option<&'a GitHubUrl>,
1616
pub cursor: usize,
1717
pub scroll_offset: usize,
18-
#[allow(dead_code)]
1918
pub status_msg: &'a str,
2019
pub is_downloading: bool,
2120
pub ascii_mode: bool,
2221
pub folder_sizes: &'a HashMap<String, u64>,
22+
pub is_searching: bool,
23+
pub search_query: &'a str,
2324
}
2425

2526
pub fn render(f: &mut Frame, area: Rect, state: &BrowserState) {
26-
let chunks = if state.is_downloading {
27-
Layout::default()
28-
.direction(Direction::Vertical)
29-
.constraints([
30-
Constraint::Length(3), // Breadcrumb
31-
Constraint::Min(10), // File list
32-
Constraint::Length(2), // Download status
33-
Constraint::Length(2),
34-
])
35-
.split(area)
36-
} else {
37-
Layout::default()
38-
.direction(Direction::Vertical)
39-
.constraints([
40-
Constraint::Length(3), // Breadcrumb
41-
Constraint::Min(10), // File list
42-
Constraint::Length(1), // Spacer
43-
Constraint::Length(2),
44-
])
45-
.split(area)
46-
};
27+
let chunks = Layout::default()
28+
.direction(Direction::Vertical)
29+
.constraints([
30+
Constraint::Length(3), // Breadcrumb
31+
Constraint::Min(10), // File list
32+
if state.is_downloading {
33+
Constraint::Length(2)
34+
} else {
35+
Constraint::Length(0)
36+
},
37+
if state.is_searching {
38+
Constraint::Length(3)
39+
} else {
40+
Constraint::Length(0)
41+
},
42+
Constraint::Length(2), // Help
43+
])
44+
.split(area);
4745

4846
let breadcrumb_text = if let Some(url) = state.current_url {
4947
format!(" {}/{} : {}", url.owner, url.repo, url.path)
@@ -161,20 +159,26 @@ pub fn render(f: &mut Frame, area: Rect, state: &BrowserState) {
161159
.unwrap_or_else(|| format!("{:>12}", ""))
162160
};
163161

164-
let display_name = if item.name.len() > 35 {
165-
if let Some(dot_pos) = item.name.rfind('.') {
166-
let ext = &item.name[dot_pos..];
167-
let name_part = &item.name[..dot_pos];
162+
let source_name = if state.is_searching {
163+
&item.path
164+
} else {
165+
&item.name
166+
};
167+
168+
let display_name = if source_name.len() > 35 {
169+
if let Some(dot_pos) = source_name.rfind('.') {
170+
let ext = &source_name[dot_pos..];
171+
let name_part = &source_name[..dot_pos];
168172
if name_part.len() > 30 {
169173
format!("{}.....{}", &name_part[..30], ext)
170174
} else {
171-
item.name.clone()
175+
source_name.clone()
172176
}
173177
} else {
174-
format!("{}.....", &item.name[..35])
178+
format!("{}.....", &source_name[..35])
175179
}
176180
} else {
177-
item.name.clone()
181+
source_name.clone()
178182
};
179183

180184
let name_with_icon = format!("{}{}", icon, display_name);
@@ -244,6 +248,18 @@ pub fn render(f: &mut Frame, area: Rect, state: &BrowserState) {
244248
f.render_widget(status, chunks[2]);
245249
}
246250

251+
// Search Bar
252+
if state.is_searching {
253+
let search_text = state.search_query.to_string();
254+
let search_bar = Paragraph::new(search_text).block(
255+
Block::default()
256+
.borders(Borders::ALL)
257+
.title(" Search ")
258+
.border_style(Style::default().fg(SUCCESS_COLOR)),
259+
);
260+
f.render_widget(search_bar, chunks[3]);
261+
}
262+
247263
let help_spans = vec![
248264
Span::styled(" ", Style::default()),
249265
Span::styled(
@@ -329,5 +345,5 @@ pub fn render(f: &mut Frame, area: Rect, state: &BrowserState) {
329345
let help = Paragraph::new(Line::from(help_spans))
330346
.alignment(ratatui::layout::Alignment::Center)
331347
.style(Style::default().bg(BG_COLOR));
332-
f.render_widget(help, chunks[3]);
348+
f.render_widget(help, chunks[4]);
333349
}

src/ui/components/input.rs

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,14 @@ use ratatui::{
88

99
use crate::ui::theme::*;
1010

11-
pub fn render(f: &mut Frame, area: Rect, input_text: &str, status_msg: &str, cursor_visible: bool) {
11+
pub fn render(
12+
f: &mut Frame,
13+
area: Rect,
14+
input_text: &str,
15+
url_cursor: usize,
16+
status_msg: &str,
17+
cursor_visible: bool,
18+
) {
1219
let vertical_layout = Layout::default()
1320
.direction(Direction::Vertical)
1421
.constraints([
@@ -96,13 +103,41 @@ pub fn render(f: &mut Frame, area: Rect, input_text: &str, status_msg: &str, cur
96103
])
97104
.split(vertical_layout[4]);
98105

99-
let display_text = if cursor_visible {
100-
format!("{}_", input_text)
106+
let display_content = if input_text.is_empty() {
107+
if cursor_visible {
108+
Line::from(vec![
109+
Span::styled("_", Style::default().fg(FG_COLOR)),
110+
Span::styled(
111+
" (Press Tab to auto-fill GitHub URL)",
112+
Style::default()
113+
.fg(BORDER_COLOR)
114+
.add_modifier(Modifier::ITALIC),
115+
),
116+
])
117+
} else {
118+
Line::from(vec![
119+
Span::styled(" ", Style::default().fg(FG_COLOR)),
120+
Span::styled(
121+
" (Press Tab to auto-fill GitHub URL)",
122+
Style::default()
123+
.fg(BORDER_COLOR)
124+
.add_modifier(Modifier::ITALIC),
125+
),
126+
])
127+
}
128+
} else if cursor_visible {
129+
let mut s = input_text.to_string();
130+
if url_cursor >= s.len() {
131+
s.push('_');
132+
} else {
133+
s.replace_range(url_cursor..url_cursor + 1, "_");
134+
}
135+
Line::from(Span::raw(s))
101136
} else {
102-
format!("{} ", input_text)
137+
Line::from(Span::raw(input_text.to_string()))
103138
};
104139

105-
let input = Paragraph::new(display_text)
140+
let input = Paragraph::new(display_content)
106141
.block(
107142
Block::default()
108143
.borders(Borders::ALL)

0 commit comments

Comments
 (0)