Add HuggingFace upload support and HF_TOKEN env variable
keypair34 committed
Apr 22, 2026 at 22:34 UTC
34726f80ee9cd1349a536b488bf48cb9d8818f04
11 files changed
+1067
-7
.github/workflows/ci.yml
+4
@@ -56,6 +56,7 @@ jobs:
56
ONDE_APP_SECRET: ${{ secrets.ONDE_APP_SECRET }}
57
GRESIQ_API_KEY: ${{ secrets.GRESIQ_API_KEY }}
58
GRESIQ_API_SECRET: ${{ secrets.GRESIQ_API_SECRET }}
59
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
60
61
test:
62
name: Test
@@ -80,6 +81,7 @@ jobs:
81
ONDE_APP_SECRET: ${{ secrets.ONDE_APP_SECRET }}
82
GRESIQ_API_KEY: ${{ secrets.GRESIQ_API_KEY }}
83
GRESIQ_API_SECRET: ${{ secrets.GRESIQ_API_SECRET }}
84
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
85
86
check:
87
name: Cargo check
@@ -104,6 +106,7 @@ jobs:
106
ONDE_APP_SECRET: ${{ secrets.ONDE_APP_SECRET }}
107
GRESIQ_API_KEY: ${{ secrets.GRESIQ_API_KEY }}
108
GRESIQ_API_SECRET: ${{ secrets.GRESIQ_API_SECRET }}
109
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
110
111
build:
112
name: Build (aarch64-apple-darwin)
@@ -127,6 +130,7 @@ jobs:
130
ONDE_APP_SECRET: ${{ secrets.ONDE_APP_SECRET }}
131
GRESIQ_API_KEY: ${{ secrets.GRESIQ_API_KEY }}
132
GRESIQ_API_SECRET: ${{ secrets.GRESIQ_API_SECRET }}
133
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
134
135
ci:
136
name: CI status
.github/workflows/release-crates.yml
+1
@@ -19,6 +19,7 @@ env:
19
ONDE_APP_SECRET: ${{ secrets.ONDE_APP_SECRET }}
20
GRESIQ_API_KEY: ${{ secrets.GRESIQ_API_KEY }}
21
GRESIQ_API_SECRET: ${{ secrets.GRESIQ_API_SECRET }}
22
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
23
24
jobs:
25
verify:
.github/workflows/release-github.yml
+1
@@ -21,6 +21,7 @@ env:
21
ONDE_APP_SECRET: ${{ secrets.ONDE_APP_SECRET }}
22
GRESIQ_API_KEY: ${{ secrets.GRESIQ_API_KEY }}
23
GRESIQ_API_SECRET: ${{ secrets.GRESIQ_API_SECRET }}
24
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
25
26
jobs:
27
build:
.github/workflows/release-pypi.yml
+2
-1
@@ -20,6 +20,7 @@ env:
20
ONDE_APP_SECRET: ${{ secrets.ONDE_APP_SECRET }}
21
GRESIQ_API_KEY: ${{ secrets.GRESIQ_API_KEY }}
22
GRESIQ_API_SECRET: ${{ secrets.GRESIQ_API_SECRET }}
23
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
24
25
jobs:
26
build-wheels:
@@ -103,7 +104,7 @@ jobs:
104
working-directory: pypi
105
args: --release --locked --compatibility pypi --out dist
106
before-script-linux: yum install -y perl-core
106
- docker-options: -e ONDE_APP_ID -e ONDE_APP_SECRET -e GRESIQ_API_KEY -e GRESIQ_API_SECRET
107
+ docker-options: -e ONDE_APP_ID -e ONDE_APP_SECRET -e GRESIQ_API_KEY -e GRESIQ_API_SECRET -e HF_TOKEN
108
109
- name: Upload wheel artifact
110
uses: actions/upload-artifact@v4
Cargo.lock
+1
@@ -2083,6 +2083,7 @@ dependencies = [
2083
"reqwest",
2084
"serde",
2085
"serde_json",
2086
+ "sha2",
2087
"smbcloud-auth-sdk",
2088
"smbcloud-gresiq-sdk",
2089
"smbcloud-model",
Cargo.toml
+1
@@ -39,6 +39,7 @@ tokenizers = { version = "0.20", default-features = false, features = ["onig"] }
39
serde = { version = "1", features = ["derive"] }
40
serde_json = "1"
41
half = "2"
42
+sha2 = "0.10"
43
44
# Metal and Accelerate are macOS-only frameworks; Cargo merges features so on
45
# macOS these are added on top of the base candle-core dep above.
build.rs
+16
-5
@@ -3,24 +3,20 @@ fn main() {
3
// as environment variables, so this is a no-op there.
4
let _ = dotenvy::dotenv();
5
6
+ // Required variables — build fails if missing.
7
for var in [
8
"ONDE_APP_ID",
9
"ONDE_APP_SECRET",
10
"GRESIQ_API_KEY",
11
"GRESIQ_API_SECRET",
12
] {
12
- // Re-run the build script if any of these change.
13
println!("cargo:rerun-if-env-changed={var}");
14
15
match std::env::var(var) {
16
Ok(val) => {
17
- // Forward the value to the compilation unit so env!() resolves.
17
println!("cargo:rustc-env={var}={val}");
18
}
19
Err(_) => {
21
- // Fail the build loudly rather than silently baking in an
22
- // empty string. Add the missing variable to .env (local) or
23
- // to the CI environment (GitHub Actions secrets).
20
eprintln!("error: required build variable `{var}` is not set.");
21
eprintln!(" → Add it to .env for local builds.");
22
eprintln!(" → Add it as a GitHub Actions secret for CI builds.");
@@ -29,6 +25,21 @@ fn main() {
25
}
26
}
27
28
+ // Optional variables — baked in when present, empty string when absent.
29
+ {
30
+ let var = "HF_TOKEN";
31
+ println!("cargo:rerun-if-env-changed={var}");
32
+ match std::env::var(var) {
33
+ Ok(val) => {
34
+ println!("cargo:rustc-env={var}={val}");
35
+ }
36
+ Err(_) => {
37
+ // Forward an empty value so env!() still compiles.
38
+ println!("cargo:rustc-env={var}=");
39
+ }
40
+ }
41
+ }
42
+
43
// Rebuild whenever .env itself changes.
44
println!("cargo:rerun-if-changed=.env");
45
}
src/app.rs
+149
-1
@@ -21,6 +21,7 @@ pub use smbcloud_gresiq_sdk::{OndeApp, OndeModel};
21
// Set these in .env for local builds; inject as secrets in CI.
22
pub(crate) const ONDE_APP_ID: &str = env!("ONDE_APP_ID");
23
pub(crate) const ONDE_APP_SECRET: &str = env!("ONDE_APP_SECRET");
24
+pub(crate) const HF_TOKEN: &str = env!("HF_TOKEN");
25
26
// GresIQ API credentials for the Onde tenant — distinct from Auth above.
27
pub(crate) const GRESIQ_API_KEY: &str = env!("GRESIQ_API_KEY");
@@ -42,6 +43,7 @@ pub enum Screen {
43
Downloads,
44
ModelDetail,
45
FineTune,
46
+ GgufDetail,
47
}
48
49
/// What kind of artifact was found on disk.
@@ -171,6 +173,8 @@ pub enum AuthEvent {
173
// merge + GGUF export
174
MergeProgress(crate::merge::MergeProgress),
175
GgufProgress(crate::gguf::GgufProgress),
176
+ // HF upload
177
+ UploadProgress(crate::hf_upload::UploadProgress),
178
}
179
180
pub struct App {
@@ -236,6 +240,11 @@ pub struct App {
240
// adapters discovered on the model detail screen
241
pub adapter_list: Vec<AdapterEntry>,
242
pub adapter_cursor: usize,
243
+ // GGUF detail + upload
244
+ pub selected_gguf: Option<AdapterEntry>,
245
+ pub upload_running: bool,
246
+ pub upload_progress: Option<crate::hf_upload::UploadProgress>,
247
+ pub upload_repo_name: String,
248
}
249
250
impl App {
@@ -290,6 +299,10 @@ impl App {
299
merged_model_dir: None,
300
adapter_list: Vec::new(),
301
adapter_cursor: 0,
302
+ selected_gguf: None,
303
+ upload_running: false,
304
+ upload_progress: None,
305
+ upload_repo_name: String::new(),
306
}
307
}
308
@@ -319,7 +332,8 @@ impl App {
332
| AuthEvent::HfSearchResults(_)
333
| AuthEvent::HfSearchFailed(_)
334
| AuthEvent::MergeProgress(_)
322
- | AuthEvent::GgufProgress(_) => {}
335
+ | AuthEvent::GgufProgress(_)
336
+ | AuthEvent::UploadProgress(_) => {}
337
_ => {
338
self.busy = false;
339
}
@@ -530,6 +544,16 @@ impl App {
544
}
545
self.gguf_progress = Some(progress);
546
}
547
+ AuthEvent::UploadProgress(progress) => {
548
+ match &progress {
549
+ crate::hf_upload::UploadProgress::Done { .. }
550
+ | crate::hf_upload::UploadProgress::Failed(_) => {
551
+ self.upload_running = false;
552
+ }
553
+ _ => {}
554
+ }
555
+ self.upload_progress = Some(progress);
556
+ }
557
}
558
}
559
}
@@ -648,6 +672,7 @@ fn handle_key(
672
Screen::Models => handle_key_models(app, key, tx),
673
Screen::Downloads => handle_key_downloads(app, key, tx),
674
Screen::ModelDetail => handle_key_model_detail(app, key, tx),
675
+ Screen::GgufDetail => handle_key_gguf_detail(app, key, tx),
676
Screen::FineTune => handle_key_finetune(app, key, tx),
677
}
678
}
@@ -1041,6 +1066,32 @@ fn handle_key_model_detail(
1066
app.adapter_cursor += 1;
1067
}
1068
}
1069
+ // Enter on a GGUF artifact — open GGUF detail screen
1070
+ (Enter, _)
1071
+ if app
1072
+ .adapter_list
1073
+ .get(app.adapter_cursor)
1074
+ .is_some_and(|a| a.kind == ArtifactKind::Gguf) =>
1075
+ {
1076
+ if let Some(entry) = app.adapter_list.get(app.adapter_cursor) {
1077
+ // Derive a default repo name from model + file
1078
+ let base_name = app
1079
+ .downloads
1080
+ .get(app.downloads_cursor)
1081
+ .map(|m| m.model_id.replace('/', "-"))
1082
+ .unwrap_or_else(|| "finetuned-model".to_string());
1083
+ let stem = entry
1084
+ .file_name
1085
+ .strip_suffix(".gguf")
1086
+ .unwrap_or(&entry.file_name);
1087
+ app.upload_repo_name = format!("ondeinference/{base_name}-{stem}");
1088
+ app.selected_gguf = Some(entry.clone());
1089
+ app.upload_progress = None;
1090
+ app.upload_running = false;
1091
+ app.screen = Screen::GgufDetail;
1092
+ app.status = Status::neutral("GGUF model details. u · upload to HuggingFace");
1093
+ }
1094
+ }
1095
// Enter or 'm' — merge the selected LoRA adapter (skip fine-tuning)
1096
(Enter, _) | (Char('m'), KeyModifiers::NONE)
1097
if app
@@ -1271,6 +1322,103 @@ fn make_artifact_entry(
1322
})
1323
}
1324
1325
+fn handle_key_gguf_detail(
1326
+ app: &mut App,
1327
+ key: crossterm::event::KeyEvent,
1328
+ tx: mpsc::UnboundedSender<AuthEvent>,
1329
+) {
1330
+ use KeyCode::*;
1331
+
1332
+ // While uploading, only allow Ctrl+C
1333
+ if app.upload_running {
1334
+ if matches!(
1335
+ (key.code, key.modifiers),
1336
+ (Char('c'), KeyModifiers::CONTROL)
1337
+ ) {
1338
+ app.should_quit = true;
1339
+ }
1340
+ return;
1341
+ }
1342
+
1343
+ // If upload is done or failed, Esc goes back; otherwise show result
1344
+ if let Some(crate::hf_upload::UploadProgress::Done { ref url }) = app.upload_progress {
1345
+ let url_owned = url.clone();
1346
+ match (key.code, key.modifiers) {
1347
+ (Esc, _) => {
1348
+ app.screen = Screen::ModelDetail;
1349
+ app.selected_gguf = None;
1350
+ app.upload_progress = None;
1351
+ app.status = Status::success(format!("Uploaded: {url_owned}"));
1352
+ }
1353
+ (Char('c'), KeyModifiers::CONTROL) => {
1354
+ app.should_quit = true;
1355
+ }
1356
+ _ => {}
1357
+ }
1358
+ return;
1359
+ }
1360
+
1361
+ match (key.code, key.modifiers) {
1362
+ (Esc, _) => {
1363
+ app.screen = Screen::ModelDetail;
1364
+ app.selected_gguf = None;
1365
+ app.upload_progress = None;
1366
+ app.status = Status::neutral("Back to model.");
1367
+ }
1368
+ (Char('c'), KeyModifiers::CONTROL) => {
1369
+ app.should_quit = true;
1370
+ }
1371
+ // Edit the repo name field
1372
+ (Backspace, _) => {
1373
+ app.upload_repo_name.pop();
1374
+ }
1375
+ // Enter — start the upload
1376
+ (Enter, _) => {
1377
+ if app.upload_repo_name.is_empty() {
1378
+ app.status = Status::error("Repo name cannot be empty.");
1379
+ return;
1380
+ }
1381
+ let token = HF_TOKEN.to_string();
1382
+ if token.is_empty() {
1383
+ app.status = Status::error("No HF_TOKEN set. Add it to .env and rebuild.");
1384
+ return;
1385
+ }
1386
+ let Some(ref gguf) = app.selected_gguf else {
1387
+ app.status = Status::error("No GGUF file selected.");
1388
+ return;
1389
+ };
1390
+
1391
+ let config = crate::hf_upload::UploadConfig {
1392
+ file_path: gguf.path.clone(),
1393
+ repo_id: app.upload_repo_name.clone(),
1394
+ path_in_repo: gguf.file_name.clone(),
1395
+ hf_token: token,
1396
+ commit_message: format!("Upload fine-tuned GGUF: {}", gguf.file_name),
1397
+ };
1398
+
1399
+ app.upload_running = true;
1400
+ app.upload_progress = None;
1401
+ app.status = Status::neutral("Uploading to HuggingFace…");
1402
+
1403
+ let (upload_tx, mut upload_rx) = mpsc::unbounded_channel();
1404
+ let auth_tx = tx.clone();
1405
+ tokio::spawn(async move {
1406
+ crate::hf_upload::start_upload(config, upload_tx).await;
1407
+ });
1408
+ tokio::spawn(async move {
1409
+ while let Some(progress) = upload_rx.recv().await {
1410
+ let _ = auth_tx.send(AuthEvent::UploadProgress(progress));
1411
+ }
1412
+ });
1413
+ }
1414
+ // Any printable char edits the repo name
1415
+ (Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) => {
1416
+ app.upload_repo_name.push(c);
1417
+ }
1418
+ _ => {}
1419
+ }
1420
+}
1421
+
1422
fn format_adapter_size(bytes: u64) -> String {
1423
if bytes >= 1_000_000_000 {
1424
format!("{:.1}GB", bytes as f64 / 1e9)
src/hf_upload.rs
new
+659
@@ -0,0 +1,659 @@
1
+//! Upload fine-tuned GGUF models to a HuggingFace repository.
2
+//!
3
+//! Uses the HuggingFace Hub LFS upload flow:
4
+//! 1. `POST /api/repos/create` — create the repo (409 = already exists, OK)
5
+//! 2. `POST /api/models/{repo}/preupload/main` — check upload mode
6
+//! 3. Compute SHA-256 of the local file
7
+//! 4. `POST /{repo}.git/info/lfs/objects/batch` — get the S3 upload URL
8
+//! 5. `PUT {s3_url}` — stream the file to S3
9
+//! 6. `POST /{repo}.git/info/lfs/objects/verify` — confirm the upload
10
+//! 7. `POST /api/models/{repo}/commit/main` — create the commit
11
+//!
12
+//! Progress is reported through a `tokio::sync::mpsc::UnboundedSender` so the
13
+//! TUI can drive a progress bar without blocking.
14
+
15
+use std::path::PathBuf;
16
+
17
+use anyhow::{Context, Result};
18
+use sha2::{Digest, Sha256};
19
+
20
+// ---------------------------------------------------------------------------
21
+// Public types
22
+// ---------------------------------------------------------------------------
23
+
24
+/// Everything needed to upload a single file to HuggingFace.
25
+pub struct UploadConfig {
26
+ /// Path to the local `.gguf` file.
27
+ pub file_path: PathBuf,
28
+ /// Full repo identifier, e.g. `ondeinference/qwen2.5-0.5b-finetuned`.
29
+ pub repo_id: String,
30
+ /// Destination filename inside the repo, e.g. `model-finetuned-q8_0.gguf`.
31
+ pub path_in_repo: String,
32
+ /// HuggingFace Bearer token.
33
+ pub hf_token: String,
34
+ /// Commit message shown on HuggingFace.
35
+ pub commit_message: String,
36
+}
37
+
38
+/// Progress updates emitted during the upload.
39
+pub enum UploadProgress {
40
+ /// About to create (or confirm) the HuggingFace repo.
41
+ CreatingRepo,
42
+ /// Computing SHA-256 hash of the local file.
43
+ Hashing { bytes_done: u64, bytes_total: u64 },
44
+ /// Streaming file bytes to the server.
45
+ Uploading { bytes_sent: u64, bytes_total: u64 },
46
+ /// Creating the commit on HuggingFace.
47
+ Committing,
48
+ /// Upload finished successfully.
49
+ Done { url: String },
50
+ /// Something went wrong.
51
+ Failed(String),
52
+}
53
+
54
+// ---------------------------------------------------------------------------
55
+// Public entry point
56
+// ---------------------------------------------------------------------------
57
+
58
+/// Spawn-friendly wrapper that never returns an error — failures are sent
59
+/// through `tx` as [`UploadProgress::Failed`] instead.
60
+pub async fn start_upload(
61
+ config: UploadConfig,
62
+ tx: tokio::sync::mpsc::UnboundedSender<UploadProgress>,
63
+) {
64
+ if let Err(error) = run_upload(&config, &tx).await {
65
+ eprintln!("[upload] error: {error:#}");
66
+ let _ = tx.send(UploadProgress::Failed(format!("{error:#}")));
67
+ }
68
+}
69
+
70
+// ---------------------------------------------------------------------------
71
+// Implementation
72
+// ---------------------------------------------------------------------------
73
+
74
+/// Size of each chunk streamed during upload (1 MiB).
75
+const CHUNK_SIZE: usize = 1_048_576;
76
+
77
+/// Size of each chunk read during SHA-256 hashing (4 MiB).
78
+const HASH_CHUNK_SIZE: usize = 4 * 1_048_576;
79
+
80
+async fn run_upload(
81
+ config: &UploadConfig,
82
+ tx: &tokio::sync::mpsc::UnboundedSender<UploadProgress>,
83
+) -> Result<()> {
84
+ let client = reqwest::Client::builder()
85
+ .user_agent("onde-cli/0.2")
86
+ .redirect(reqwest::redirect::Policy::limited(10))
87
+ .timeout(std::time::Duration::from_secs(7200))
88
+ .build()
89
+ .context("failed to build HTTP client")?;
90
+
91
+ let file_size = std::fs::metadata(&config.file_path)
92
+ .with_context(|| format!("failed to stat {}", config.file_path.display()))?
93
+ .len();
94
+
95
+ // -- Step 1: Create the repo (or confirm it already exists) ---------------
96
+
97
+ let _ = tx.send(UploadProgress::CreatingRepo);
98
+ create_repo(&client, &config.hf_token, &config.repo_id).await?;
99
+
100
+ // -- Step 2: Preupload check ----------------------------------------------
101
+
102
+ let upload_mode = preupload_check(
103
+ &client,
104
+ &config.hf_token,
105
+ &config.repo_id,
106
+ &config.path_in_repo,
107
+ file_size,
108
+ )
109
+ .await?;
110
+
111
+ eprintln!("[upload] upload mode: {upload_mode}");
112
+
113
+ // -- Step 3: Compute SHA-256 of the file ----------------------------------
114
+
115
+ let _ = tx.send(UploadProgress::Hashing {
116
+ bytes_done: 0,
117
+ bytes_total: file_size,
118
+ });
119
+
120
+ let sha256 = compute_sha256(&config.file_path, file_size, tx).await?;
121
+ eprintln!("[upload] sha256: {sha256}");
122
+
123
+ if upload_mode == "lfs" {
124
+ // -- Step 4: LFS batch — get the S3 upload URL ------------------------
125
+
126
+ let lfs_info = lfs_batch(
127
+ &client,
128
+ &config.hf_token,
129
+ &config.repo_id,
130
+ &sha256,
131
+ file_size,
132
+ )
133
+ .await?;
134
+
135
+ match lfs_info {
136
+ LfsBatchResult::AlreadyExists => {
137
+ eprintln!("[upload] LFS object already exists, skipping upload");
138
+ }
139
+ LfsBatchResult::NeedsUpload {
140
+ upload_url,
141
+ verify_url,
142
+ verify_token,
143
+ } => {
144
+ // -- Step 5: PUT to S3 ----------------------------------------
145
+
146
+ let _ = tx.send(UploadProgress::Uploading {
147
+ bytes_sent: 0,
148
+ bytes_total: file_size,
149
+ });
150
+
151
+ upload_to_s3(&client, &upload_url, &config.file_path, file_size, tx).await?;
152
+
153
+ // -- Step 6: LFS verify ---------------------------------------
154
+
155
+ lfs_verify(&client, &verify_url, &verify_token, &sha256, file_size).await?;
156
+ }
157
+ }
158
+
159
+ // -- Step 7: Commit with LFS pointer ----------------------------------
160
+
161
+ let _ = tx.send(UploadProgress::Committing);
162
+
163
+ lfs_commit(
164
+ &client,
165
+ &config.hf_token,
166
+ &config.repo_id,
167
+ &config.path_in_repo,
168
+ &config.commit_message,
169
+ &sha256,
170
+ file_size,
171
+ )
172
+ .await?;
173
+ } else {
174
+ // Regular (non-LFS) commit for small files.
175
+ let _ = tx.send(UploadProgress::Uploading {
176
+ bytes_sent: 0,
177
+ bytes_total: file_size,
178
+ });
179
+
180
+ regular_commit(
181
+ &client,
182
+ &config.hf_token,
183
+ &config.repo_id,
184
+ &config.path_in_repo,
185
+ &config.commit_message,
186
+ &config.file_path,
187
+ file_size,
188
+ tx,
189
+ )
190
+ .await?;
191
+ }
192
+
193
+ let model_url = format!("https://huggingface.co/{}", config.repo_id);
194
+ let _ = tx.send(UploadProgress::Done { url: model_url });
195
+
196
+ Ok(())
197
+}
198
+
199
+// ---------------------------------------------------------------------------
200
+// Step 1: Create repo
201
+// ---------------------------------------------------------------------------
202
+
203
+async fn create_repo(client: &reqwest::Client, token: &str, repo_id: &str) -> Result<()> {
204
+ // The HF API expects "name" (repo only) and "organization" as separate fields.
205
+ let create_body = if let Some((org, name)) = repo_id.split_once('/') {
206
+ serde_json::json!({
207
+ "type": "model",
208
+ "name": name,
209
+ "organization": org,
210
+ "private": false,
211
+ "sdk": "onde-cli"
212
+ })
213
+ } else {
214
+ serde_json::json!({
215
+ "type": "model",
216
+ "name": repo_id,
217
+ "private": false,
218
+ "sdk": "onde-cli"
219
+ })
220
+ };
221
+
222
+ let response = client
223
+ .post("https://huggingface.co/api/repos/create")
224
+ .bearer_auth(token)
225
+ .json(&create_body)
226
+ .send()
227
+ .await
228
+ .context("failed to reach HuggingFace API")?;
229
+
230
+ let status = response.status();
231
+ let body = response.text().await.unwrap_or_default();
232
+ eprintln!("[upload] create repo: {status} {body}");
233
+
234
+ // 200 = created, 409 = already exists — both are fine.
235
+ if !status.is_success() && status.as_u16() != 409 {
236
+ anyhow::bail!("failed to create repo {repo_id}: {status} {body}");
237
+ }
238
+
239
+ Ok(())
240
+}
241
+
242
+// ---------------------------------------------------------------------------
243
+// Step 2: Preupload check
244
+// ---------------------------------------------------------------------------
245
+
246
+async fn preupload_check(
247
+ client: &reqwest::Client,
248
+ token: &str,
249
+ repo_id: &str,
250
+ path_in_repo: &str,
251
+ file_size: u64,
252
+) -> Result<String> {
253
+ let body = serde_json::json!({
254
+ "files": [{
255
+ "path": path_in_repo,
256
+ "size": file_size,
257
+ "sample": ""
258
+ }]
259
+ });
260
+
261
+ let url = format!("https://huggingface.co/api/models/{repo_id}/preupload/main");
262
+
263
+ let response = client
264
+ .post(&url)
265
+ .bearer_auth(token)
266
+ .json(&body)
267
+ .send()
268
+ .await
269
+ .context("preupload check failed")?;
270
+
271
+ let status = response.status();
272
+ let text = response.text().await.unwrap_or_default();
273
+ eprintln!("[upload] preupload: {status} {text}");
274
+
275
+ if !status.is_success() {
276
+ // Default to LFS for large files if preupload endpoint fails.
277
+ if file_size > 10_000_000 {
278
+ return Ok("lfs".to_string());
279
+ }
280
+ anyhow::bail!("preupload check failed: {status} {text}");
281
+ }
282
+
283
+ // Parse: {"files":[{"path":"...","uploadMode":"lfs"|"regular",...}],...}
284
+ let parsed: serde_json::Value =
285
+ serde_json::from_str(&text).context("failed to parse preupload response")?;
286
+
287
+ let upload_mode = parsed
288
+ .get("files")
289
+ .and_then(|f| f.as_array())
290
+ .and_then(|arr| arr.first())
291
+ .and_then(|entry| entry.get("uploadMode"))
292
+ .and_then(|v| v.as_str())
293
+ .unwrap_or("lfs")
294
+ .to_string();
295
+
296
+ Ok(upload_mode)
297
+}
298
+
299
+// ---------------------------------------------------------------------------
300
+// Step 3: Compute SHA-256
301
+// ---------------------------------------------------------------------------
302
+
303
+async fn compute_sha256(
304
+ path: &std::path::Path,
305
+ file_size: u64,
306
+ tx: &tokio::sync::mpsc::UnboundedSender<UploadProgress>,
307
+) -> Result<String> {
308
+ use std::io::Read;
309
+
310
+ let path = path.to_path_buf();
311
+ let tx = tx.clone();
312
+
313
+ tokio::task::spawn_blocking(move || {
314
+ let mut file = std::fs::File::open(&path)
315
+ .with_context(|| format!("failed to open {}", path.display()))?;
316
+
317
+ let mut hasher = Sha256::new();
318
+ let mut buf = vec![0u8; HASH_CHUNK_SIZE];
319
+ let mut done: u64 = 0;
320
+
321
+ loop {
322
+ let n = file
323
+ .read(&mut buf)
324
+ .with_context(|| format!("failed to read {}", path.display()))?;
325
+ if n == 0 {
326
+ break;
327
+ }
328
+ hasher.update(&buf[..n]);
329
+ done += n as u64;
330
+
331
+ let _ = tx.send(UploadProgress::Hashing {
332
+ bytes_done: done,
333
+ bytes_total: file_size,
334
+ });
335
+ }
336
+
337
+ let hash = hasher.finalize();
338
+ let hex: String = hash.iter().map(|b| format!("{b:02x}")).collect();
339
+ Ok(hex)
340
+ })
341
+ .await
342
+ .context("SHA-256 task panicked")?
343
+}
344
+
345
+// ---------------------------------------------------------------------------
346
+// Step 4: LFS batch
347
+// ---------------------------------------------------------------------------
348
+
349
+enum LfsBatchResult {
350
+ /// The object already exists on the server; no upload needed.
351
+ AlreadyExists,
352
+ /// The object must be uploaded to the given URL.
353
+ NeedsUpload {
354
+ upload_url: String,
355
+ verify_url: String,
356
+ verify_token: String,
357
+ },
358
+}
359
+
360
+async fn lfs_batch(
361
+ client: &reqwest::Client,
362
+ token: &str,
363
+ repo_id: &str,
364
+ sha256: &str,
365
+ file_size: u64,
366
+) -> Result<LfsBatchResult> {
367
+ let url = format!("https://huggingface.co/{repo_id}.git/info/lfs/objects/batch");
368
+
369
+ let body = serde_json::json!({
370
+ "operation": "upload",
371
+ "transfers": ["basic"],
372
+ "objects": [{
373
+ "oid": sha256,
374
+ "size": file_size
375
+ }],
376
+ "hash_algo": "sha256"
377
+ });
378
+
379
+ let response = client
380
+ .post(&url)
381
+ .bearer_auth(token)
382
+ .header("Accept", "application/vnd.git-lfs+json")
383
+ .header("Content-Type", "application/vnd.git-lfs+json")
384
+ .json(&body)
385
+ .send()
386
+ .await
387
+ .context("LFS batch request failed")?;
388
+
389
+ let status = response.status();
390
+ let text = response.text().await.unwrap_or_default();
391
+ eprintln!(
392
+ "[upload] lfs batch: {status} (response len: {})",
393
+ text.len()
394
+ );
395
+
396
+ if !status.is_success() {
397
+ anyhow::bail!("LFS batch failed: {status} {text}");
398
+ }
399
+
400
+ let parsed: serde_json::Value =
401
+ serde_json::from_str(&text).context("failed to parse LFS batch response")?;
402
+
403
+ let obj = parsed
404
+ .get("objects")
405
+ .and_then(|o| o.as_array())
406
+ .and_then(|a| a.first())
407
+ .context("no objects in LFS batch response")?;
408
+
409
+ // If there are no "actions", the object already exists on the server.
410
+ let actions = match obj.get("actions") {
411
+ Some(a) => a,
412
+ None => return Ok(LfsBatchResult::AlreadyExists),
413
+ };
414
+
415
+ let upload_url = actions
416
+ .get("upload")
417
+ .and_then(|u| u.get("href"))
418
+ .and_then(|v| v.as_str())
419
+ .context("no upload href in LFS batch response")?
420
+ .to_string();
421
+
422
+ let verify_url = actions
423
+ .get("verify")
424
+ .and_then(|v| v.get("href"))
425
+ .and_then(|v| v.as_str())
426
+ .unwrap_or("")
427
+ .to_string();
428
+
429
+ let verify_token = actions
430
+ .get("verify")
431
+ .and_then(|v| v.get("header"))
432
+ .and_then(|h| h.get("Authorization"))
433
+ .and_then(|v| v.as_str())
434
+ .unwrap_or("")
435
+ .to_string();
436
+
437
+ Ok(LfsBatchResult::NeedsUpload {
438
+ upload_url,
439
+ verify_url,
440
+ verify_token,
441
+ })
442
+}
443
+
444
+// ---------------------------------------------------------------------------
445
+// Step 5: Upload to S3
446
+// ---------------------------------------------------------------------------
447
+
448
+async fn upload_to_s3(
449
+ client: &reqwest::Client,
450
+ upload_url: &str,
451
+ file_path: &std::path::Path,
452
+ file_size: u64,
453
+ tx: &tokio::sync::mpsc::UnboundedSender<UploadProgress>,
454
+) -> Result<()> {
455
+ let file = tokio::fs::File::open(file_path)
456
+ .await
457
+ .with_context(|| format!("failed to open {}", file_path.display()))?;
458
+
459
+ let reader = tokio::io::BufReader::new(file);
460
+ let progress_tx = tx.clone();
461
+
462
+ let stream = futures::stream::unfold(
463
+ (reader, 0u64, file_size, progress_tx),
464
+ |(mut reader, mut sent, total, progress)| async move {
465
+ use tokio::io::AsyncReadExt;
466
+ let mut buf = vec![0u8; CHUNK_SIZE];
467
+ match reader.read(&mut buf).await {
468
+ Ok(0) => None,
469
+ Ok(n) => {
470
+ sent += n as u64;
471
+ let _ = progress.send(UploadProgress::Uploading {
472
+ bytes_sent: sent,
473
+ bytes_total: total,
474
+ });
475
+ buf.truncate(n);
476
+ Some((
477
+ Ok::<_, std::io::Error>(buf),
478
+ (reader, sent, total, progress),
479
+ ))
480
+ }
481
+ Err(error) => Some((Err(error), (reader, sent, total, progress))),
482
+ }
483
+ },
484
+ );
485
+
486
+ let body = reqwest::Body::wrap_stream(stream);
487
+
488
+ let response = client
489
+ .put(upload_url)
490
+ .header("Content-Length", file_size)
491
+ .header("Content-Type", "application/octet-stream")
492
+ .body(body)
493
+ .send()
494
+ .await
495
+ .context("S3 upload request failed")?;
496
+
497
+ let status = response.status();
498
+ if !status.is_success() {
499
+ let resp_body = response.text().await.unwrap_or_default();
500
+ anyhow::bail!("S3 upload failed: {status} {resp_body}");
501
+ }
502
+
503
+ eprintln!("[upload] S3 upload complete: {status}");
504
+ Ok(())
505
+}
506
+
507
+// ---------------------------------------------------------------------------
508
+// Step 6: LFS verify
509
+// ---------------------------------------------------------------------------
510
+
511
+async fn lfs_verify(
512
+ client: &reqwest::Client,
513
+ verify_url: &str,
514
+ verify_token: &str,
515
+ sha256: &str,
516
+ file_size: u64,
517
+) -> Result<()> {
518
+ if verify_url.is_empty() {
519
+ eprintln!("[upload] no verify URL, skipping");
520
+ return Ok(());
521
+ }
522
+
523
+ let body = serde_json::json!({
524
+ "oid": sha256,
525
+ "size": file_size
526
+ });
527
+
528
+ let mut request = client
529
+ .post(verify_url)
530
+ .header("Accept", "application/vnd.git-lfs+json")
531
+ .header("Content-Type", "application/vnd.git-lfs+json")
532
+ .json(&body);
533
+
534
+ if !verify_token.is_empty() {
535
+ request = request.header("Authorization", verify_token);
536
+ }
537
+
538
+ let response = request.send().await.context("LFS verify request failed")?;
539
+
540
+ let status = response.status();
541
+ let text = response.text().await.unwrap_or_default();
542
+ eprintln!("[upload] lfs verify: {status} {text}");
543
+
544
+ if !status.is_success() {
545
+ anyhow::bail!("LFS verify failed: {status} {text}");
546
+ }
547
+
548
+ Ok(())
549
+}
550
+
551
+// ---------------------------------------------------------------------------
552
+// Step 7: LFS commit
553
+// ---------------------------------------------------------------------------
554
+
555
+async fn lfs_commit(
556
+ client: &reqwest::Client,
557
+ token: &str,
558
+ repo_id: &str,
559
+ path_in_repo: &str,
560
+ commit_message: &str,
561
+ sha256: &str,
562
+ file_size: u64,
563
+) -> Result<()> {
564
+ let url = format!("https://huggingface.co/api/models/{repo_id}/commit/main");
565
+
566
+ // Build the LFS pointer content that HuggingFace expects.
567
+ let lfs_pointer = format!(
568
+ "version https://git-lfs.github.com/spec/v1\noid sha256:{sha256}\nsize {file_size}\n"
569
+ );
570
+
571
+ let body = serde_json::json!({
572
+ "summary": commit_message,
573
+ "files": [{
574
+ "path": path_in_repo,
575
+ "content": lfs_pointer,
576
+ "lfs": {
577
+ "oid": sha256,
578
+ "size": file_size
579
+ }
580
+ }]
581
+ });
582
+
583
+ let response = client
584
+ .post(&url)
585
+ .bearer_auth(token)
586
+ .json(&body)
587
+ .send()
588
+ .await
589
+ .context("commit request failed")?;
590
+
591
+ let status = response.status();
592
+ let text = response.text().await.unwrap_or_default();
593
+ eprintln!("[upload] commit: {status} {text}");
594
+
595
+ if !status.is_success() {
596
+ anyhow::bail!("commit failed: {status} {text}");
597
+ }
598
+
599
+ Ok(())
600
+}
601
+
602
+// ---------------------------------------------------------------------------
603
+// Regular (non-LFS) commit for small files
604
+// ---------------------------------------------------------------------------
605
+
606
+#[allow(clippy::too_many_arguments)]
607
+async fn regular_commit(
608
+ client: &reqwest::Client,
609
+ token: &str,
610
+ repo_id: &str,
611
+ path_in_repo: &str,
612
+ commit_message: &str,
613
+ file_path: &std::path::Path,
614
+ file_size: u64,
615
+ tx: &tokio::sync::mpsc::UnboundedSender<UploadProgress>,
616
+) -> Result<()> {
617
+ let content = tokio::fs::read(file_path)
618
+ .await
619
+ .with_context(|| format!("failed to read {}", file_path.display()))?;
620
+
621
+ let _ = tx.send(UploadProgress::Uploading {
622
+ bytes_sent: file_size,
623
+ bytes_total: file_size,
624
+ });
625
+
626
+ // For small regular files, send content as a UTF-8 string.
627
+ // Binary files should always go through the LFS path instead.
628
+ let content_str = String::from_utf8_lossy(&content).into_owned();
629
+
630
+ let url = format!("https://huggingface.co/api/models/{repo_id}/commit/main");
631
+
632
+ let body = serde_json::json!({
633
+ "summary": commit_message,
634
+ "files": [{
635
+ "path": path_in_repo,
636
+ "content": content_str
637
+ }]
638
+ });
639
+
640
+ let _ = tx.send(UploadProgress::Committing);
641
+
642
+ let response = client
643
+ .post(&url)
644
+ .bearer_auth(token)
645
+ .json(&body)
646
+ .send()
647
+ .await
648
+ .context("commit request failed")?;
649
+
650
+ let status = response.status();
651
+ let text = response.text().await.unwrap_or_default();
652
+ eprintln!("[upload] regular commit: {status} {text}");
653
+
654
+ if !status.is_success() {
655
+ anyhow::bail!("commit failed: {status} {text}");
656
+ }
657
+
658
+ Ok(())
659
+}
src/main.rs
+1
@@ -4,6 +4,7 @@ mod gguf;
4
mod gresiq;
5
mod hf;
6
mod hf_search;
7
+mod hf_upload;
8
mod merge;
9
mod token;
10
mod ui;
src/ui.rs
+232
@@ -84,6 +84,7 @@ fn render_card(frame: &mut Frame, app: &App, area: Rect) {
84
Screen::Models => render_models(frame, app, inner),
85
Screen::Downloads => render_downloads(frame, app, inner),
86
Screen::ModelDetail => render_model_detail(frame, app, inner),
87
+ Screen::GgufDetail => render_gguf_detail(frame, app, inner),
88
Screen::FineTune => render_finetune(frame, app, inner),
89
}
90
}
@@ -1171,6 +1172,225 @@ fn render_model_detail_adapters(frame: &mut Frame, app: &App, area: Rect) {
1172
}
1173
}
1174
1175
+fn render_gguf_detail(frame: &mut Frame, app: &App, area: Rect) {
1176
+ let Some(ref gguf) = app.selected_gguf else {
1177
+ return;
1178
+ };
1179
+
1180
+ let rows = Layout::vertical([
1181
+ Constraint::Length(1), // 0 heading
1182
+ Constraint::Length(1), // 1 spacer
1183
+ Constraint::Length(1), // 2 status
1184
+ Constraint::Length(1), // 3 spacer
1185
+ Constraint::Length(1), // 4 File label
1186
+ Constraint::Length(1), // 5 File value
1187
+ Constraint::Length(1), // 6 spacer
1188
+ Constraint::Length(1), // 7 Size label
1189
+ Constraint::Length(1), // 8 Size value
1190
+ Constraint::Length(1), // 9 spacer
1191
+ Constraint::Length(1), // 10 Location label
1192
+ Constraint::Length(3), // 11 Location value (bordered, wraps)
1193
+ Constraint::Length(1), // 12 spacer
1194
+ Constraint::Length(1), // 13 Upload heading
1195
+ Constraint::Length(1), // 14 Repo Name label
1196
+ Constraint::Length(3), // 15 Repo Name input
1197
+ Constraint::Length(1), // 16 spacer
1198
+ Constraint::Length(1), // 17 upload status / progress
1199
+ Constraint::Length(1), // 18 spacer
1200
+ Constraint::Min(0), // 19 rest
1201
+ Constraint::Length(1), // 20 hint
1202
+ ])
1203
+ .split(area);
1204
+
1205
+ // Heading
1206
+ frame.render_widget(
1207
+ Paragraph::new(Line::from(Span::styled(
1208
+ format!("GGUF Model — {}", gguf.file_name),
1209
+ Style::new().fg(C_INK).bold(),
1210
+ ))),
1211
+ rows[0],
1212
+ );
1213
+
1214
+ render_status(frame, app, rows[2]);
1215
+
1216
+ // File name
1217
+ render_detail_field(frame, "File", &gguf.file_name, rows[4], rows[5]);
1218
+
1219
+ // Size + modified
1220
+ let size_modified = format!("{} {}", gguf.size, gguf.modified);
1221
+ render_detail_field(frame, "Size", &size_modified, rows[7], rows[8]);
1222
+
1223
+ // Full path in a bordered box
1224
+ frame.render_widget(
1225
+ Paragraph::new("Location").style(Style::new().fg(C_MUTED)),
1226
+ rows[10],
1227
+ );
1228
+ let path_block = Block::new()
1229
+ .borders(Borders::ALL)
1230
+ .border_type(BorderType::Rounded)
1231
+ .border_style(Style::new().fg(C_LINE))
1232
+ .style(Style::new().bg(C_SURFACE_STRONG));
1233
+ let path_inner = path_block.inner(rows[11]);
1234
+ frame.render_widget(path_block, rows[11]);
1235
+ frame.render_widget(
1236
+ Paragraph::new(gguf.path.to_string_lossy().to_string())
1237
+ .style(Style::new().fg(C_NEON))
1238
+ .wrap(Wrap { trim: true }),
1239
+ path_inner,
1240
+ );
1241
+
1242
+ // Upload section
1243
+ frame.render_widget(
1244
+ Paragraph::new("Upload to HuggingFace").style(Style::new().fg(C_MUTED)),
1245
+ rows[13],
1246
+ );
1247
+
1248
+ // Repo name input
1249
+ frame.render_widget(
1250
+ Paragraph::new("Repo Name").style(Style::new().fg(C_MUTED)),
1251
+ rows[14],
1252
+ );
1253
+
1254
+ let input_focused = !app.upload_running
1255
+ && !matches!(
1256
+ app.upload_progress,
1257
+ Some(crate::hf_upload::UploadProgress::Done { .. })
1258
+ );
1259
+ let border_style = if input_focused {
1260
+ Style::new().fg(C_NEON)
1261
+ } else {
1262
+ Style::new().fg(C_LINE)
1263
+ };
1264
+ let input_block = Block::new()
1265
+ .borders(Borders::ALL)
1266
+ .border_type(BorderType::Rounded)
1267
+ .border_style(border_style)
1268
+ .style(Style::new().bg(C_SURFACE_STRONG));
1269
+ let input_inner = input_block.inner(rows[15]);
1270
+ frame.render_widget(input_block, rows[15]);
1271
+ frame.render_widget(
1272
+ Paragraph::new(app.upload_repo_name.as_str()).style(Style::new().fg(C_TEXT)),
1273
+ input_inner,
1274
+ );
1275
+ if input_focused {
1276
+ let cursor_x = (input_inner.x + app.upload_repo_name.chars().count() as u16)
1277
+ .min(input_inner.x + input_inner.width.saturating_sub(1));
1278
+ frame.set_cursor_position((cursor_x, input_inner.y));
1279
+ }
1280
+
1281
+ // Upload progress / status
1282
+ render_upload_status(frame, app, rows[17]);
1283
+
1284
+ // Hint
1285
+ let hint = if app.upload_running {
1286
+ "Ctrl+C · quit"
1287
+ } else if matches!(
1288
+ app.upload_progress,
1289
+ Some(crate::hf_upload::UploadProgress::Done { .. })
1290
+ ) {
1291
+ "Esc · back"
1292
+ } else {
1293
+ "Enter · upload Esc · back"
1294
+ };
1295
+ frame.render_widget(
1296
+ Paragraph::new(hint).style(Style::new().fg(C_MUTED)),
1297
+ rows[20],
1298
+ );
1299
+}
1300
+
1301
+fn render_upload_status(frame: &mut Frame, app: &App, area: Rect) {
1302
+ match &app.upload_progress {
1303
+ Some(crate::hf_upload::UploadProgress::CreatingRepo) => {
1304
+ frame.render_widget(
1305
+ Paragraph::new("⠿ Creating repository…").style(Style::new().fg(C_MUTED)),
1306
+ area,
1307
+ );
1308
+ }
1309
+ Some(crate::hf_upload::UploadProgress::Hashing {
1310
+ bytes_done,
1311
+ bytes_total,
1312
+ }) => {
1313
+ let pct = if *bytes_total > 0 {
1314
+ (*bytes_done as f64 / *bytes_total as f64 * 100.0) as u64
1315
+ } else {
1316
+ 0
1317
+ };
1318
+ frame.render_widget(
1319
+ Paragraph::new(format!("⠿ Hashing… {}%", pct)).style(Style::new().fg(C_MUTED)),
1320
+ area,
1321
+ );
1322
+ }
1323
+ Some(crate::hf_upload::UploadProgress::Committing) => {
1324
+ frame.render_widget(
1325
+ Paragraph::new("⠿ Creating commit…").style(Style::new().fg(C_MUTED)),
1326
+ area,
1327
+ );
1328
+ }
1329
+ Some(crate::hf_upload::UploadProgress::Uploading {
1330
+ bytes_sent,
1331
+ bytes_total,
1332
+ }) => {
1333
+ let pct = if *bytes_total > 0 {
1334
+ (*bytes_sent as f64 / *bytes_total as f64 * 100.0) as u64
1335
+ } else {
1336
+ 0
1337
+ };
1338
+ let sent_str = fmt_bytes(*bytes_sent as i64);
1339
+ let total_str = fmt_bytes(*bytes_total as i64);
1340
+ let bar_width = area.width.saturating_sub(30) as usize;
1341
+ let filled = if *bytes_total > 0 {
1342
+ (*bytes_sent as f64 / *bytes_total as f64 * bar_width as f64) as usize
1343
+ } else {
1344
+ 0
1345
+ };
1346
+ let empty = bar_width.saturating_sub(filled);
1347
+ let bar = format!(
1348
+ "{}{} {}/{} {}%",
1349
+ "█".repeat(filled),
1350
+ "░".repeat(empty),
1351
+ sent_str,
1352
+ total_str,
1353
+ pct
1354
+ );
1355
+ frame.render_widget(Paragraph::new(bar).style(Style::new().fg(C_NEON)), area);
1356
+ }
1357
+ Some(crate::hf_upload::UploadProgress::Done { url }) => {
1358
+ frame.render_widget(
1359
+ Paragraph::new(Line::from(vec![
1360
+ Span::styled("✓ ", Style::new().fg(C_NEON)),
1361
+ Span::styled("Uploaded → ", Style::new().fg(C_NEON).bold()),
1362
+ Span::styled(url.as_str(), Style::new().fg(C_TEXT)),
1363
+ ])),
1364
+ area,
1365
+ );
1366
+ }
1367
+ Some(crate::hf_upload::UploadProgress::Failed(msg)) => {
1368
+ frame.render_widget(
1369
+ Paragraph::new(Line::from(vec![
1370
+ Span::styled("✗ Upload failed: ", Style::new().fg(C_DANGER).bold()),
1371
+ Span::styled(msg.as_str(), Style::new().fg(C_DANGER)),
1372
+ ])),
1373
+ area,
1374
+ );
1375
+ }
1376
+ None => {
1377
+ if crate::app::HF_TOKEN.is_empty() {
1378
+ frame.render_widget(
1379
+ Paragraph::new("⚠ No HF_TOKEN in .env — upload disabled")
1380
+ .style(Style::new().fg(C_DANGER)),
1381
+ area,
1382
+ );
1383
+ } else {
1384
+ frame.render_widget(
1385
+ Paragraph::new("Ready to upload. Press Enter to start.")
1386
+ .style(Style::new().fg(C_MUTED)),
1387
+ area,
1388
+ );
1389
+ }
1390
+ }
1391
+ }
1392
+}
1393
+
1394
fn fmt_bytes(bytes: i64) -> String {
1395
if bytes >= 1_000_000_000 {
1396
format!("{:.1}GB", bytes as f64 / 1e9)
@@ -1897,6 +2117,18 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
2117
keys.push(Span::styled(" · quit", Style::new().fg(C_MUTED)));
2118
keys
2119
}
2120
+ Screen::GgufDetail if app.upload_running => vec![
2121
+ Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
2122
+ Span::styled(" · quit", Style::new().fg(C_MUTED)),
2123
+ ],
2124
+ Screen::GgufDetail => vec![
2125
+ Span::styled("Enter", Style::new().fg(C_NEON)),
2126
+ Span::styled(" · upload ", Style::new().fg(C_MUTED)),
2127
+ Span::styled("Esc", Style::new().fg(C_NEON)),
2128
+ Span::styled(" · back ", Style::new().fg(C_MUTED)),
2129
+ Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
2130
+ Span::styled(" · quit", Style::new().fg(C_MUTED)),
2131
+ ],
2132
};
2133
2134
frame.render_widget(