max / makenotwork
743 files changed,
+50567 insertions,
-27977 deletions
| @@ -657,11 +657,7 @@ impl MnwApiClient { | |||
| 657 | 657 | } | |
| 658 | 658 | ||
| 659 | 659 | /// Publish an item (set is_public=true). | |
| 660 | - | pub async fn publish_item( | |
| 661 | - | &self, | |
| 662 | - | user_id: &str, | |
| 663 | - | item_id: &str, | |
| 664 | - | ) -> anyhow::Result<ItemDetail> { | |
| 660 | + | pub async fn publish_item(&self, user_id: &str, item_id: &str) -> anyhow::Result<ItemDetail> { | |
| 665 | 661 | let url = format!( | |
| 666 | 662 | "{}/api/internal/creator/items/{}/publish", | |
| 667 | 663 | self.base_url, item_id | |
| @@ -679,11 +675,7 @@ impl MnwApiClient { | |||
| 679 | 675 | } | |
| 680 | 676 | ||
| 681 | 677 | /// Unpublish an item (set is_public=false). | |
| 682 | - | pub async fn unpublish_item( | |
| 683 | - | &self, | |
| 684 | - | user_id: &str, | |
| 685 | - | item_id: &str, | |
| 686 | - | ) -> anyhow::Result<ItemDetail> { | |
| 678 | + | pub async fn unpublish_item(&self, user_id: &str, item_id: &str) -> anyhow::Result<ItemDetail> { | |
| 687 | 679 | let url = format!( | |
| 688 | 680 | "{}/api/internal/creator/items/{}/unpublish", | |
| 689 | 681 | self.base_url, item_id | |
| @@ -895,9 +887,9 @@ impl MnwApiClient { | |||
| 895 | 887 | ) -> anyhow::Result<()> { | |
| 896 | 888 | use tokio::io::AsyncReadExt; | |
| 897 | 889 | ||
| 898 | - | let mut file = tokio::fs::File::open(file_path).await.map_err(|e| { | |
| 899 | - | anyhow::anyhow!("opening {} for upload: {e}", file_path.display()) | |
| 900 | - | })?; | |
| 890 | + | let mut file = tokio::fs::File::open(file_path) | |
| 891 | + | .await | |
| 892 | + | .map_err(|e| anyhow::anyhow!("opening {} for upload: {e}", file_path.display()))?; | |
| 901 | 893 | ||
| 902 | 894 | let mut completed: Vec<(i32, String)> = Vec::with_capacity(start.part_count as usize); | |
| 903 | 895 | let mut uploaded: u64 = 0; | |
| @@ -982,8 +974,10 @@ impl MnwApiClient { | |||
| 982 | 974 | } | |
| 983 | 975 | Err(e) => { | |
| 984 | 976 | if attempt >= 3 { | |
| 985 | - | return Err(anyhow::Error::new(e) | |
| 986 | - | .context(format!("part {} failed after {attempt} attempts", part.part_number))); | |
| 977 | + | return Err(anyhow::Error::new(e).context(format!( | |
| 978 | + | "part {} failed after {attempt} attempts", | |
| 979 | + | part.part_number | |
| 980 | + | ))); | |
| 987 | 981 | } | |
| 988 | 982 | e.to_string() | |
| 989 | 983 | } | |
| @@ -1217,11 +1211,7 @@ impl MnwApiClient { | |||
| 1217 | 1211 | } | |
| 1218 | 1212 | ||
| 1219 | 1213 | /// Revoke a license key. | |
| 1220 | - | pub async fn revoke_license_key( | |
| 1221 | - | &self, | |
| 1222 | - | user_id: &str, | |
| 1223 | - | key_id: &str, | |
| 1224 | - | ) -> anyhow::Result<()> { | |
| 1214 | + | pub async fn revoke_license_key(&self, user_id: &str, key_id: &str) -> anyhow::Result<()> { | |
| 1225 | 1215 | let url = format!( | |
| 1226 | 1216 | "{}/api/internal/creator/keys/{}/revoke", | |
| 1227 | 1217 | self.base_url, key_id | |
| @@ -1241,11 +1231,7 @@ impl MnwApiClient { | |||
| 1241 | 1231 | // ── Analytics ── | |
| 1242 | 1232 | ||
| 1243 | 1233 | /// Get analytics data (timeseries, period comparison, top projects). | |
| 1244 | - | pub async fn get_analytics( | |
| 1245 | - | &self, | |
| 1246 | - | user_id: &str, | |
| 1247 | - | range: &str, | |
| 1248 | - | ) -> anyhow::Result<AnalyticsData> { | |
| 1234 | + | pub async fn get_analytics(&self, user_id: &str, range: &str) -> anyhow::Result<AnalyticsData> { | |
| 1249 | 1235 | let url = format!("{}/api/internal/creator/analytics", self.base_url); | |
| 1250 | 1236 | let resp = self | |
| 1251 | 1237 | .http | |
| @@ -1348,61 +1334,117 @@ impl MnwApiClient { | |||
| 1348 | 1334 | ||
| 1349 | 1335 | // ── Tags ── | |
| 1350 | 1336 | ||
| 1351 | - | pub async fn list_item_tags(&self, user_id: &str, item_id: &str) -> anyhow::Result<Vec<TagInfo>> { | |
| 1352 | - | let url = format!("{}/api/internal/creator/items/{}/tags", self.base_url, item_id); | |
| 1353 | - | let resp = self.http.get(&url).bearer_auth(&self.service_token) | |
| 1337 | + | pub async fn list_item_tags( | |
| 1338 | + | &self, | |
| 1339 | + | user_id: &str, | |
| 1340 | + | item_id: &str, | |
| 1341 | + | ) -> anyhow::Result<Vec<TagInfo>> { | |
| 1342 | + | let url = format!( | |
| 1343 | + | "{}/api/internal/creator/items/{}/tags", | |
| 1344 | + | self.base_url, item_id | |
| 1345 | + | ); | |
| 1346 | + | let resp = self | |
| 1347 | + | .http | |
| 1348 | + | .get(&url) | |
| 1349 | + | .bearer_auth(&self.service_token) | |
| 1354 | 1350 | .header("X-MNW-Actor", self.actor_header()) | |
| 1355 | - | .query(&[("user_id", user_id)]).send().await?; | |
| 1351 | + | .query(&[("user_id", user_id)]) | |
| 1352 | + | .send() | |
| 1353 | + | .await?; | |
| 1356 | 1354 | json_response(resp, "list_item_tags").await | |
| 1357 | 1355 | } | |
| 1358 | 1356 | ||
| 1359 | 1357 | pub async fn search_tags(&self, query: &str) -> anyhow::Result<Vec<TagInfo>> { | |
| 1360 | 1358 | let url = format!("{}/api/internal/tags/search", self.base_url); | |
| 1361 | - | let resp = self.http.get(&url).bearer_auth(&self.service_token) | |
| 1359 | + | let resp = self | |
| 1360 | + | .http | |
| 1361 | + | .get(&url) | |
| 1362 | + | .bearer_auth(&self.service_token) | |
| 1362 | 1363 | .header("X-MNW-Actor", self.actor_header()) | |
| 1363 | - | .query(&[("q", query)]).send().await?; | |
| 1364 | + | .query(&[("q", query)]) | |
| 1365 | + | .send() | |
| 1366 | + | .await?; | |
| 1364 | 1367 | json_response(resp, "search_tags").await | |
| 1365 | 1368 | } | |
| 1366 | 1369 | ||
| 1367 | - | pub async fn add_item_tag(&self, user_id: &str, item_id: &str, tag_id: &str) -> anyhow::Result<()> { | |
| 1370 | + | pub async fn add_item_tag( | |
| 1371 | + | &self, | |
| 1372 | + | user_id: &str, | |
| 1373 | + | item_id: &str, | |
| 1374 | + | tag_id: &str, | |
| 1375 | + | ) -> anyhow::Result<()> { | |
| 1368 | 1376 | let url = format!("{}/api/internal/creator/items/tags", self.base_url); | |
| 1369 | - | let resp = self.http.post(&url).bearer_auth(&self.service_token) | |
| 1377 | + | let resp = self | |
| 1378 | + | .http | |
| 1379 | + | .post(&url) | |
| 1380 | + | .bearer_auth(&self.service_token) | |
| 1370 | 1381 | .header("X-MNW-Actor", self.actor_header()) | |
| 1371 | 1382 | .json(&serde_json::json!({"user_id": user_id, "item_id": item_id, "tag_id": tag_id})) | |
| 1372 | - | .send().await?; | |
| 1383 | + | .send() | |
| 1384 | + | .await?; | |
| 1373 | 1385 | empty_response(resp, "add_item_tag").await | |
| 1374 | 1386 | } | |
| 1375 | 1387 | ||
| 1376 | 1388 | // Unused by the TUI today; kept so the client mirrors the full | |
| 1377 | 1389 | // /api/internal surface rather than only the paths one caller happens to hit. | |
| 1378 | 1390 | #[allow(dead_code)] | |
| 1379 | - | pub async fn remove_item_tag(&self, user_id: &str, item_id: &str, tag_id: &str) -> anyhow::Result<()> { | |
| 1391 | + | pub async fn remove_item_tag( | |
| 1392 | + | &self, | |
| 1393 | + | user_id: &str, | |
| 1394 | + | item_id: &str, | |
| 1395 | + | tag_id: &str, | |
| 1396 | + | ) -> anyhow::Result<()> { | |
| 1380 | 1397 | let url = format!("{}/api/internal/creator/items/tags/remove", self.base_url); | |
| 1381 | - | let resp = self.http.post(&url).bearer_auth(&self.service_token) | |
| 1398 | + | let resp = self | |
| 1399 | + | .http | |
| 1400 | + | .post(&url) | |
| 1401 | + | .bearer_auth(&self.service_token) | |
| 1382 | 1402 | .header("X-MNW-Actor", self.actor_header()) | |
| 1383 | 1403 | .json(&serde_json::json!({"user_id": user_id, "item_id": item_id, "tag_id": tag_id})) | |
| 1384 | - | .send().await?; | |
| 1404 | + | .send() | |
| 1405 | + | .await?; | |
| 1385 | 1406 | empty_response(resp, "remove_item_tag").await | |
| 1386 | 1407 | } | |
| 1387 | 1408 | ||
| 1388 | 1409 | // ── Broadcast ── | |
| 1389 | 1410 | ||
| 1390 | - | pub async fn send_broadcast(&self, user_id: &str, subject: &str, body: &str) -> anyhow::Result<BroadcastResult> { | |
| 1411 | + | pub async fn send_broadcast( | |
| 1412 | + | &self, | |
| 1413 | + | user_id: &str, | |
| 1414 | + | subject: &str, | |
| 1415 | + | body: &str, | |
| 1416 | + | ) -> anyhow::Result<BroadcastResult> { | |
| 1391 | 1417 | let url = format!("{}/api/internal/creator/broadcast", self.base_url); | |
| 1392 | - | let resp = self.http.post(&url).bearer_auth(&self.service_token) | |
| 1418 | + | let resp = self | |
| 1419 | + | .http | |
| 1420 | + | .post(&url) | |
| 1421 | + | .bearer_auth(&self.service_token) | |
| 1393 | 1422 | .header("X-MNW-Actor", self.actor_header()) | |
| 1394 | 1423 | .json(&serde_json::json!({"user_id": user_id, "subject": subject, "body": body})) | |
| 1395 | - | .send().await?; | |
| 1424 | + | .send() | |
| 1425 | + | .await?; | |
| 1396 | 1426 | json_response(resp, "send_broadcast").await | |
| 1397 | 1427 | } | |
| 1398 | 1428 | ||
| 1399 | 1429 | // ── Tiers ── | |
| 1400 | 1430 | ||
| 1401 | - | pub async fn list_tiers(&self, user_id: &str, project_id: &str) -> anyhow::Result<Vec<TierInfo>> { | |
| 1402 | - | let url = format!("{}/api/internal/creator/projects/{}/tiers", self.base_url, project_id); | |
| 1403 | - | let resp = self.http.get(&url).bearer_auth(&self.service_token) | |
| 1431 | + | pub async fn list_tiers( | |
| 1432 | + | &self, | |
| 1433 | + | user_id: &str, | |
| 1434 | + | project_id: &str, | |
| 1435 | + | ) -> anyhow::Result<Vec<TierInfo>> { | |
| 1436 | + | let url = format!( | |
| 1437 | + | "{}/api/internal/creator/projects/{}/tiers", | |
| 1438 | + | self.base_url, project_id | |
| 1439 | + | ); | |
| 1440 | + | let resp = self | |
| 1441 | + | .http | |
| 1442 | + | .get(&url) | |
| 1443 | + | .bearer_auth(&self.service_token) | |
| 1404 | 1444 | .header("X-MNW-Actor", self.actor_header()) | |
| 1405 | - | .query(&[("user_id", user_id)]).send().await?; | |
| 1445 | + | .query(&[("user_id", user_id)]) | |
| 1446 | + | .send() | |
| 1447 | + | .await?; | |
| 1406 | 1448 | json_response(resp, "list_tiers").await | |
| 1407 | 1449 | } | |
| 1408 | 1450 | ||
| @@ -1410,28 +1452,54 @@ impl MnwApiClient { | |||
| 1410 | 1452 | ||
| 1411 | 1453 | pub async fn list_collections(&self, user_id: &str) -> anyhow::Result<Vec<CollectionInfo>> { | |
| 1412 | 1454 | let url = format!("{}/api/internal/creator/collections", self.base_url); | |
| 1413 | - | let resp = self.http.get(&url).bearer_auth(&self.service_token) | |
| 1455 | + | let resp = self | |
| 1456 | + | .http | |
| 1457 | + | .get(&url) | |
| 1458 | + | .bearer_auth(&self.service_token) | |
| 1414 | 1459 | .header("X-MNW-Actor", self.actor_header()) | |
| 1415 | - | .query(&[("user_id", user_id)]).send().await?; | |
| 1460 | + | .query(&[("user_id", user_id)]) | |
| 1461 | + | .send() | |
| 1462 | + | .await?; | |
| 1416 | 1463 | json_response(resp, "list_collections").await | |
| 1417 | 1464 | } | |
| 1418 | 1465 | ||
| 1419 | 1466 | #[allow(dead_code)] | |
| 1420 | - | pub async fn create_collection(&self, user_id: &str, slug: &str, title: &str) -> anyhow::Result<serde_json::Value> { | |
| 1467 | + | pub async fn create_collection( | |
| 1468 | + | &self, | |
| 1469 | + | user_id: &str, | |
| 1470 | + | slug: &str, | |
| 1471 | + | title: &str, | |
| 1472 | + | ) -> anyhow::Result<serde_json::Value> { | |
| 1421 | 1473 | let url = format!("{}/api/internal/creator/collections", self.base_url); | |
| 1422 | - | let resp = self.http.post(&url).bearer_auth(&self.service_token) | |
| 1474 | + | let resp = self | |
| 1475 | + | .http | |
| 1476 | + | .post(&url) | |
| 1477 | + | .bearer_auth(&self.service_token) | |
| 1423 | 1478 | .header("X-MNW-Actor", self.actor_header()) | |
| 1424 | 1479 | .json(&serde_json::json!({"user_id": user_id, "slug": slug, "title": title})) | |
| 1425 | - | .send().await?; | |
| 1480 | + | .send() | |
| 1481 | + | .await?; | |
| 1426 | 1482 | json_response(resp, "create_collection").await | |
| 1427 | 1483 | } | |
| 1428 | 1484 | ||
| 1429 | 1485 | #[allow(dead_code)] | |
| 1430 | - | pub async fn delete_collection(&self, user_id: &str, collection_id: &str) -> anyhow::Result<()> { | |
| 1431 | - | let url = format!("{}/api/internal/creator/collections/{}", self.base_url, collection_id); | |
| 1432 | - | let resp = self.http.delete(&url).bearer_auth(&self.service_token) | |
| 1486 | + | pub async fn delete_collection( | |
| 1487 | + | &self, | |
| 1488 | + | user_id: &str, | |
| 1489 | + | collection_id: &str, | |
| 1490 | + | ) -> anyhow::Result<()> { | |
| 1491 | + | let url = format!( | |
| 1492 | + | "{}/api/internal/creator/collections/{}", | |
| 1493 | + | self.base_url, collection_id | |
| 1494 | + | ); | |
| 1495 | + | let resp = self | |
| 1496 | + | .http | |
| 1497 | + | .delete(&url) | |
| 1498 | + | .bearer_auth(&self.service_token) | |
| 1433 | 1499 | .header("X-MNW-Actor", self.actor_header()) | |
| 1434 | - | .query(&[("user_id", user_id)]).send().await?; | |
| 1500 | + | .query(&[("user_id", user_id)]) | |
| 1501 | + | .send() | |
| 1502 | + | .await?; | |
| 1435 | 1503 | empty_response(resp, "delete_collection").await | |
| 1436 | 1504 | } | |
| 1437 | 1505 | ||
| @@ -1439,36 +1507,57 @@ impl MnwApiClient { | |||
| 1439 | 1507 | ||
| 1440 | 1508 | pub async fn get_domain(&self, user_id: &str) -> anyhow::Result<Option<DomainInfo>> { | |
| 1441 | 1509 | let url = format!("{}/api/internal/creator/domain", self.base_url); | |
| 1442 | - | let resp = self.http.get(&url).bearer_auth(&self.service_token) | |
| 1510 | + | let resp = self | |
| 1511 | + | .http | |
| 1512 | + | .get(&url) | |
| 1513 | + | .bearer_auth(&self.service_token) | |
| 1443 | 1514 | .header("X-MNW-Actor", self.actor_header()) | |
| 1444 | - | .query(&[("user_id", user_id)]).send().await?; | |
| 1515 | + | .query(&[("user_id", user_id)]) | |
| 1516 | + | .send() | |
| 1517 | + | .await?; | |
| 1445 | 1518 | let val: serde_json::Value = json_response(resp, "get_domain").await?; | |
| 1446 | - | if val.is_null() { return Ok(None); } | |
| 1519 | + | if val.is_null() { | |
| 1520 | + | return Ok(None); | |
| 1521 | + | } | |
| 1447 | 1522 | Ok(serde_json::from_value(val).ok()) | |
| 1448 | 1523 | } | |
| 1449 | 1524 | ||
| 1450 | 1525 | pub async fn add_domain(&self, user_id: &str, domain: &str) -> anyhow::Result<DomainInfo> { | |
| 1451 | 1526 | let url = format!("{}/api/internal/creator/domain", self.base_url); | |
| 1452 | - | let resp = self.http.post(&url).bearer_auth(&self.service_token) | |
| 1527 | + | let resp = self | |
| 1528 | + | .http | |
| 1529 | + | .post(&url) | |
| 1530 | + | .bearer_auth(&self.service_token) | |
| 1453 | 1531 | .header("X-MNW-Actor", self.actor_header()) | |
| 1454 | 1532 | .json(&serde_json::json!({"user_id": user_id, "domain": domain})) | |
| 1455 | - | .send().await?; | |
| 1533 | + | .send() | |
| 1534 | + | .await?; | |
| 1456 | 1535 | json_response(resp, "add_domain").await | |
| 1457 | 1536 | } | |
| 1458 | 1537 | ||
| 1459 | 1538 | pub async fn verify_domain(&self, user_id: &str) -> anyhow::Result<DomainVerifyResult> { | |
| 1460 | 1539 | let url = format!("{}/api/internal/creator/domain/verify", self.base_url); | |
| 1461 | - | let resp = self.http.post(&url).bearer_auth(&self.service_token) | |
| 1540 | + | let resp = self | |
| 1541 | + | .http | |
| 1542 | + | .post(&url) | |
| 1543 | + | .bearer_auth(&self.service_token) | |
| 1462 | 1544 | .header("X-MNW-Actor", self.actor_header()) | |
| 1463 | - | .query(&[("user_id", user_id)]).send().await?; | |
| 1545 | + | .query(&[("user_id", user_id)]) | |
| 1546 | + | .send() | |
| 1547 | + | .await?; | |
| 1464 | 1548 | json_response(resp, "verify_domain").await | |
| 1465 | 1549 | } | |
| 1466 | 1550 | ||
| 1467 | 1551 | pub async fn remove_domain(&self, user_id: &str) -> anyhow::Result<()> { | |
| 1468 | 1552 | let url = format!("{}/api/internal/creator/domain", self.base_url); | |
| 1469 | - | let resp = self.http.delete(&url).bearer_auth(&self.service_token) | |
| 1553 | + | let resp = self | |
| 1554 | + | .http | |
| 1555 | + | .delete(&url) | |
| 1556 | + | .bearer_auth(&self.service_token) | |
| 1470 | 1557 | .header("X-MNW-Actor", self.actor_header()) | |
| 1471 | - | .query(&[("user_id", user_id)]).send().await?; | |
| 1558 | + | .query(&[("user_id", user_id)]) | |
| 1559 | + | .send() | |
| 1560 | + | .await?; | |
| 1472 | 1561 | empty_response(resp, "remove_domain").await | |
| 1473 | 1562 | } | |
| 1474 | 1563 | } |
| @@ -29,11 +29,7 @@ pub(crate) fn sanitize_api_error(e: &anyhow::Error) -> String { | |||
| 29 | 29 | } | |
| 30 | 30 | ||
| 31 | 31 | /// Execute a non-interactive command and return the output bytes. | |
| 32 | - | pub async fn execute( | |
| 33 | - | command_line: &str, | |
| 34 | - | user: &UserInfo, | |
| 35 | - | api: &MnwApiClient, | |
| 36 | - | ) -> Vec<u8> { | |
| 32 | + | pub async fn execute(command_line: &str, user: &UserInfo, api: &MnwApiClient) -> Vec<u8> { | |
| 37 | 33 | let parts: Vec<&str> = command_line.split_whitespace().collect(); | |
| 38 | 34 | if parts.is_empty() { | |
| 39 | 35 | return help_text(); | |
| @@ -115,11 +111,15 @@ async fn cmd_project_create( | |||
| 115 | 111 | if !user.can_create_projects { | |
| 116 | 112 | return b"Error: your account cannot create projects. Upgrade your tier at makenot.work/pricing\r\n".to_vec(); | |
| 117 | 113 | } | |
| 118 | - | match api.create_project(&user.user_id, title, project_type, description).await { | |
| 114 | + | match api | |
| 115 | + | .create_project(&user.user_id, title, project_type, description) | |
| 116 | + | .await | |
| 117 | + | { | |
| 119 | 118 | Ok(p) => format!( | |
| 120 | 119 | "Created project: {} (slug: {}, type: {})\r\n", | |
| 121 | 120 | p.title, p.slug, p.project_type | |
| 122 | - | ).into_bytes(), | |
| 121 | + | ) | |
| 122 | + | .into_bytes(), | |
| 123 | 123 | Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(), | |
| 124 | 124 | } | |
| 125 | 125 | } | |
| @@ -157,12 +157,7 @@ async fn cmd_projects(user: &UserInfo, api: &MnwApiClient, json: bool) -> Vec<u8 | |||
| 157 | 157 | } | |
| 158 | 158 | } | |
| 159 | 159 | ||
| 160 | - | async fn cmd_analytics( | |
| 161 | - | user: &UserInfo, | |
| 162 | - | api: &MnwApiClient, | |
| 163 | - | range: &str, | |
| 164 | - | json: bool, | |
| 165 | - | ) -> Vec<u8> { | |
| 160 | + | async fn cmd_analytics(user: &UserInfo, api: &MnwApiClient, range: &str, json: bool) -> Vec<u8> { | |
| 166 | 161 | match api.get_analytics(&user.user_id, range).await { | |
| 167 | 162 | Ok(data) => { | |
| 168 | 163 | if json { | |
| @@ -285,12 +280,7 @@ async fn cmd_promo_list(user: &UserInfo, api: &MnwApiClient, json: bool) -> Vec< | |||
| 285 | 280 | } | |
| 286 | 281 | } | |
| 287 | 282 | ||
| 288 | - | async fn cmd_promo_create( | |
| 289 | - | user: &UserInfo, | |
| 290 | - | api: &MnwApiClient, | |
| 291 | - | code: &str, | |
| 292 | - | pct: &str, | |
| 293 | - | ) -> Vec<u8> { | |
| 283 | + | async fn cmd_promo_create(user: &UserInfo, api: &MnwApiClient, code: &str, pct: &str) -> Vec<u8> { | |
| 294 | 284 | if code.is_empty() { | |
| 295 | 285 | return b"Usage: promo create CODE DISCOUNT_PCT\r\n".to_vec(); | |
| 296 | 286 | } | |
| @@ -304,12 +294,7 @@ async fn cmd_promo_create( | |||
| 304 | 294 | } | |
| 305 | 295 | } | |
| 306 | 296 | ||
| 307 | - | async fn cmd_blog_list( | |
| 308 | - | user: &UserInfo, | |
| 309 | - | api: &MnwApiClient, | |
| 310 | - | slug: &str, | |
| 311 | - | json: bool, | |
| 312 | - | ) -> Vec<u8> { | |
| 297 | + | async fn cmd_blog_list(user: &UserInfo, api: &MnwApiClient, slug: &str, json: bool) -> Vec<u8> { | |
| 313 | 298 | if slug.is_empty() { | |
| 314 | 299 | return b"Usage: blog list <project-slug>\r\n".to_vec(); | |
| 315 | 300 | } | |
| @@ -359,10 +344,15 @@ async fn cmd_broadcast(user: &UserInfo, api: &MnwApiClient, subject: &str, body: | |||
| 359 | 344 | if subject.is_empty() || body.is_empty() { | |
| 360 | 345 | return b"Usage: broadcast --subject \"Subject line\" --body \"Body text\"\r\n\ | |
| 361 | 346 | \x20 Aliases: -s, -b\r\n\ | |
| 362 | - | \x20 Sends an email to all your followers (1/24h limit).\r\n".to_vec(); | |
| 347 | + | \x20 Sends an email to all your followers (1/24h limit).\r\n" | |
| 348 | + | .to_vec(); | |
| 363 | 349 | } | |
| 364 | 350 | match api.send_broadcast(&user.user_id, subject, body).await { | |
| 365 | - | Ok(result) => format!("Broadcast sent to {} followers.\r\n", result.recipient_count).into_bytes(), | |
| 351 | + | Ok(result) => format!( | |
| 352 | + | "Broadcast sent to {} followers.\r\n", | |
| 353 | + | result.recipient_count | |
| 354 | + | ) | |
| 355 | + | .into_bytes(), | |
| 366 | 356 | Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(), | |
| 367 | 357 | } | |
| 368 | 358 | } | |
| @@ -404,9 +394,10 @@ async fn cmd_domain_show(user: &UserInfo, api: &MnwApiClient) -> Vec<u8> { | |||
| 404 | 394 | let status = if d.verified { "verified" } else { "pending" }; | |
| 405 | 395 | let mut out = format!("Domain: {} ({})\r\n", d.domain, status); | |
| 406 | 396 | if !d.verified | |
| 407 | - | && let Some(ref instr) = d.instructions { | |
| 408 | - | out.push_str(&format!("{}\r\n", instr)); | |
| 409 | - | } | |
| 397 | + | && let Some(ref instr) = d.instructions | |
| 398 | + | { | |
| 399 | + | out.push_str(&format!("{}\r\n", instr)); | |
| 400 | + | } | |
| 410 | 401 | out.into_bytes() | |
| 411 | 402 | } | |
| 412 | 403 | Ok(None) => b"No custom domain configured.\r\nUsage: domain add <domain>\r\n".to_vec(), | |
| @@ -455,7 +446,12 @@ pub async fn execute_pipe_upload( | |||
| 455 | 446 | anyhow::bail!("no data received on stdin"); | |
| 456 | 447 | } | |
| 457 | 448 | ||
| 458 | - | let ext = upload.filename.rsplit('.').next().unwrap_or("").to_lowercase(); | |
| 449 | + | let ext = upload | |
| 450 | + | .filename | |
| 451 | + | .rsplit('.') | |
| 452 | + | .next() | |
| 453 | + | .unwrap_or("") | |
| 454 | + | .to_lowercase(); | |
| 459 | 455 | let classification = staging::classify_extension(&ext) | |
| 460 | 456 | .ok_or_else(|| anyhow::anyhow!( | |
| 461 | 457 | "unsupported file type: .{ext}\r\nSupported: mp3, wav, flac, ogg, m4a, aac, zip, dmg, exe, appimage, deb, clap, vst3" | |
| @@ -463,27 +459,32 @@ pub async fn execute_pipe_upload( | |||
| 463 | 459 | ||
| 464 | 460 | // Find project by slug | |
| 465 | 461 | let projects = api.get_projects(&user.user_id).await?; | |
| 466 | - | let project = projects.iter() | |
| 462 | + | let project = projects | |
| 463 | + | .iter() | |
| 467 | 464 | .find(|p| p.slug == upload.project_slug) | |
| 468 | 465 | .ok_or_else(|| anyhow::anyhow!("project not found: {}", upload.project_slug))?; | |
| 469 | 466 | ||
| 470 | 467 | // Create item | |
| 471 | - | let item = api.create_item( | |
| 472 | - | &user.user_id, | |
| 473 | - | &project.id, | |
| 474 | - | &upload.title, | |
| 475 | - | classification.item_type, | |
| 476 | - | upload.price_cents, | |
| 477 | - | ).await?; | |
| 468 | + | let item = api | |
| 469 | + | .create_item( | |
| 470 | + | &user.user_id, | |
| 471 | + | &project.id, | |
| 472 | + | &upload.title, | |
| 473 | + | classification.item_type, | |
| 474 | + | upload.price_cents, | |
| 475 | + | ) | |
| 476 | + | .await?; | |
| 478 | 477 | ||
| 479 | 478 | // Presign upload | |
| 480 | - | let presign = api.presign_upload( | |
| 481 | - | &user.user_id, | |
| 482 | - | &item.item_id, | |
| 483 | - | classification.file_type, | |
| 484 | - | &upload.filename, | |
| 485 | - | classification.content_type, | |
| 486 | - | ).await?; | |
| 479 | + | let presign = api | |
| 480 | + | .presign_upload( | |
| 481 | + | &user.user_id, | |
| 482 | + | &item.item_id, | |
| 483 | + | classification.file_type, | |
| 484 | + | &upload.filename, | |
| 485 | + | classification.content_type, | |
| 486 | + | ) | |
| 487 | + | .await?; | |
| 487 | 488 | ||
| 488 | 489 | // Upload data directly to S3 | |
| 489 | 490 | let resp = reqwest::Client::new() | |
| @@ -503,7 +504,8 @@ pub async fn execute_pipe_upload( | |||
| 503 | 504 | &item.item_id, | |
| 504 | 505 | classification.file_type, | |
| 505 | 506 | &presign.s3_key, | |
| 506 | - | ).await?; | |
| 507 | + | ) | |
| 508 | + | .await?; | |
| 507 | 509 | ||
| 508 | 510 | // Publish | |
| 509 | 511 | api.publish_item(&user.user_id, &item.item_id).await?; | |
| @@ -557,27 +559,28 @@ pub fn help_text() -> Vec<u8> { | |||
| 557 | 559 | fn extract_flag(parts: &[&str], flags: &[&str]) -> Option<String> { | |
| 558 | 560 | for (i, part) in parts.iter().enumerate() { | |
| 559 | 561 | if flags.contains(part) | |
| 560 | - | && let Some(&next) = parts.get(i + 1) { | |
| 561 | - | // If the value starts with a quote, collect until closing quote | |
| 562 | - | if next.starts_with('"') || next.starts_with('\'') { | |
| 563 | - | let quote = next.as_bytes()[0] as char; | |
| 564 | - | let stripped = &next[1..]; | |
| 565 | - | if stripped.ends_with(quote) { | |
| 566 | - | return Some(stripped[..stripped.len() - 1].to_string()); | |
| 567 | - | } | |
| 568 | - | let mut collected = stripped.to_string(); | |
| 569 | - | for &subsequent in &parts[i + 2..] { | |
| 570 | - | collected.push(' '); | |
| 571 | - | if subsequent.ends_with(quote) { | |
| 572 | - | collected.push_str(&subsequent[..subsequent.len() - 1]); | |
| 573 | - | return Some(collected); | |
| 574 | - | } | |
| 575 | - | collected.push_str(subsequent); | |
| 562 | + | && let Some(&next) = parts.get(i + 1) | |
| 563 | + | { | |
| 564 | + | // If the value starts with a quote, collect until closing quote | |
| 565 | + | if next.starts_with('"') || next.starts_with('\'') { | |
| 566 | + | let quote = next.as_bytes()[0] as char; | |
| 567 | + | let stripped = &next[1..]; | |
| 568 | + | if stripped.ends_with(quote) { | |
| 569 | + | return Some(stripped[..stripped.len() - 1].to_string()); | |
| 570 | + | } | |
| 571 | + | let mut collected = stripped.to_string(); | |
| 572 | + | for &subsequent in &parts[i + 2..] { | |
| 573 | + | collected.push(' '); | |
| 574 | + | if subsequent.ends_with(quote) { | |
| 575 | + | collected.push_str(&subsequent[..subsequent.len() - 1]); | |
| 576 | + | return Some(collected); | |
| 576 | 577 | } | |
| 577 | - | return Some(collected); | |
| 578 | + | collected.push_str(subsequent); | |
| 578 | 579 | } | |
| 579 | - | return Some(next.to_string()); | |
| 580 | + | return Some(collected); | |
| 580 | 581 | } | |
| 582 | + | return Some(next.to_string()); | |
| 583 | + | } | |
| 581 | 584 | } | |
| 582 | 585 | None | |
| 583 | 586 | } | |
| @@ -626,24 +629,43 @@ mod tests { | |||
| 626 | 629 | #[test] | |
| 627 | 630 | fn extract_flag_simple() { | |
| 628 | 631 | let parts = vec!["broadcast", "--subject", "Hello", "--body", "World"]; | |
| 629 | - | assert_eq!(extract_flag(&parts, &["--subject", "-s"]), Some("Hello".to_string())); | |
| 630 | - | assert_eq!(extract_flag(&parts, &["--body", "-b"]), Some("World".to_string())); | |
| 632 | + | assert_eq!( | |
| 633 | + | extract_flag(&parts, &["--subject", "-s"]), | |
| 634 | + | Some("Hello".to_string()) | |
| 635 | + | ); | |
| 636 | + | assert_eq!( | |
| 637 | + | extract_flag(&parts, &["--body", "-b"]), | |
| 638 | + | Some("World".to_string()) | |
| 639 | + | ); | |
| 631 | 640 | } | |
| 632 | 641 | ||
| 633 | 642 | #[test] | |
| 634 | 643 | fn extract_flag_short() { | |
| 635 | 644 | let parts = vec!["broadcast", "-s", "Hi", "-b", "There"]; | |
| 636 | - | assert_eq!(extract_flag(&parts, &["--subject", "-s"]), Some("Hi".to_string())); | |
| 645 | + | assert_eq!( | |
| 646 | + | extract_flag(&parts, &["--subject", "-s"]), | |
| 647 | + | Some("Hi".to_string()) | |
| 648 | + | ); | |
| 637 | 649 | } | |
| 638 | 650 | ||
| 639 | 651 | #[test] | |
| 640 | 652 | fn extract_flag_quoted_multiword() { | |
| 641 | - | let parts = vec!["broadcast", "--subject", "\"Hello", "everyone\"", "--body", "\"text\""]; | |
| 653 | + | let parts = vec![ | |
| 654 | + | "broadcast", | |
| 655 | + | "--subject", | |
| 656 | + | "\"Hello", | |
| 657 | + | "everyone\"", | |
| 658 | + | "--body", | |
| 659 | + | "\"text\"", | |
| 660 | + | ]; | |
| 642 | 661 | assert_eq!( | |
| 643 | 662 | extract_flag(&parts, &["--subject", "-s"]), | |
| 644 | 663 | Some("Hello everyone".to_string()) | |
| 645 | 664 | ); | |
| 646 | - | assert_eq!(extract_flag(&parts, &["--body", "-b"]), Some("text".to_string())); | |
| 665 | + | assert_eq!( | |
| 666 | + | extract_flag(&parts, &["--body", "-b"]), | |
| 667 | + | Some("text".to_string()) | |
| 668 | + | ); | |
| 647 | 669 | } | |
| 648 | 670 | ||
| 649 | 671 | #[test] |
| @@ -26,8 +26,8 @@ impl Config { | |||
| 26 | 26 | .parse() | |
| 27 | 27 | .map_err(|_| anyhow::anyhow!("Invalid SSH_PORT"))?; | |
| 28 | 28 | ||
| 29 | - | let api_url = std::env::var("MNW_API_URL") | |
| 30 | - | .unwrap_or_else(|_| "http://localhost:3000".to_string()); | |
| 29 | + | let api_url = | |
| 30 | + | std::env::var("MNW_API_URL").unwrap_or_else(|_| "http://localhost:3000".to_string()); | |
| 31 | 31 | ||
| 32 | 32 | let service_token = std::env::var("MNW_SERVICE_TOKEN") | |
| 33 | 33 | .map_err(|_| anyhow::anyhow!("MNW_SERVICE_TOKEN is required"))?; | |
| @@ -40,8 +40,7 @@ impl Config { | |||
| 40 | 40 | .unwrap_or_else(|_| "/var/lib/mnw-cli/staging".to_string()) | |
| 41 | 41 | .into(); | |
| 42 | 42 | ||
| 43 | - | let git_user = std::env::var("GIT_SUDO_USER") | |
| 44 | - | .unwrap_or_else(|_| "git".to_string()); | |
| 43 | + | let git_user = std::env::var("GIT_SUDO_USER").unwrap_or_else(|_| "git".to_string()); | |
| 45 | 44 | ||
| 46 | 45 | Ok(Config { | |
| 47 | 46 | port, |
| @@ -22,9 +22,9 @@ mod tui; | |||
| 22 | 22 | ||
| 23 | 23 | use std::sync::Arc; | |
| 24 | 24 | ||
| 25 | + | use russh::MethodKind; | |
| 25 | 26 | use russh::keys::{self, Algorithm, PrivateKey, ssh_key}; | |
| 26 | 27 | use russh::server::Server as _; | |
| 27 | - | use russh::MethodKind; | |
| 28 | 28 | use tokio::signal; | |
| 29 | 29 | use tracing_subscriber::EnvFilter; | |
| 30 | 30 | ||
| @@ -128,8 +128,7 @@ fn load_or_generate_host_key(path: &std::path::Path) -> anyhow::Result<PrivateKe | |||
| 128 | 128 | // the standalone rand crate; russh 0.62 dropped its rand_core RC pin, so | |
| 129 | 129 | // both now agree on rand 0.10 and the workaround is gone. This is the | |
| 130 | 130 | // same call russh itself uses to generate keys. | |
| 131 | - | let key = | |
| 132 | - | ssh_key::private::PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519)?; | |
| 131 | + | let key = ssh_key::private::PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519)?; | |
| 133 | 132 | // Save to disk in OpenSSH format | |
| 134 | 133 | let pem = key.to_openssh(ssh_key::LineEnding::LF)?; | |
| 135 | 134 | if let Some(parent) = path.parent() { |
| @@ -10,7 +10,7 @@ | |||
| 10 | 10 | ||
| 11 | 11 | use std::path::PathBuf; | |
| 12 | 12 | ||
| 13 | - | use anyhow::{bail, Context, Result}; | |
| 13 | + | use anyhow::{Context, Result, bail}; | |
| 14 | 14 | use synckit_client::{SyncKitClient, SyncKitConfig}; | |
| 15 | 15 | ||
| 16 | 16 | const DEFAULT_SERVER: &str = "https://makenot.work"; | |
| @@ -156,10 +156,16 @@ fn parse_args(flags: &[String]) -> Result<PublishArgs> { | |||
| 156 | 156 | let arch = arch.ok_or_else(|| missing("--arch"))?; | |
| 157 | 157 | ||
| 158 | 158 | if !ALLOWED_TARGETS.contains(&target.as_str()) { | |
| 159 | - | bail!("invalid --target '{target}'. Allowed: {}", ALLOWED_TARGETS.join(", ")); | |
| 159 | + | bail!( | |
| 160 | + | "invalid --target '{target}'. Allowed: {}", | |
| 161 | + | ALLOWED_TARGETS.join(", ") | |
| 162 | + | ); | |
| 160 | 163 | } | |
| 161 | 164 | if !ALLOWED_ARCHS.contains(&arch.as_str()) { | |
| 162 | - | bail!("invalid --arch '{arch}'. Allowed: {}", ALLOWED_ARCHS.join(", ")); | |
| 165 | + | bail!( | |
| 166 | + | "invalid --arch '{arch}'. Allowed: {}", | |
| 167 | + | ALLOWED_ARCHS.join(", ") | |
| 168 | + | ); | |
| 163 | 169 | } | |
| 164 | 170 | ||
| 165 | 171 | Ok(PublishArgs { | |
| @@ -340,10 +346,12 @@ async fn authenticate_oauth(client: &SyncKitClient, key: &str) -> Result<()> { | |||
| 340 | 346 | open_browser(&url); | |
| 341 | 347 | println!(" waiting for the authorization redirect (5 min timeout)..."); | |
| 342 | 348 | ||
| 343 | - | let (code, got_state) = | |
| 344 | - | tokio::time::timeout(std::time::Duration::from_secs(300), wait_for_code(&listener)) | |
| 345 | - | .await | |
| 346 | - | .context("timed out waiting for OAuth authorization")??; | |
| 349 | + | let (code, got_state) = tokio::time::timeout( | |
| 350 | + | std::time::Duration::from_secs(300), | |
| 351 | + | wait_for_code(&listener), | |
| 352 | + | ) | |
| 353 | + | .await | |
| 354 | + | .context("timed out waiting for OAuth authorization")??; | |
| 347 | 355 | ||
| 348 | 356 | if got_state.as_deref() != Some(state.as_str()) { | |
| 349 | 357 | bail!("OAuth state mismatch — aborting (possible CSRF or a stale redirect)"); | |
| @@ -359,9 +367,7 @@ async fn authenticate_oauth(client: &SyncKitClient, key: &str) -> Result<()> { | |||
| 359 | 367 | /// Accept localhost connections until one carries an OAuth `code` (ignoring | |
| 360 | 368 | /// incidental requests like `/favicon.ico`), reply with a small page, and return | |
| 361 | 369 | /// `(code, state)`. | |
| 362 | - | async fn wait_for_code( | |
| 363 | - | listener: &tokio::net::TcpListener, | |
| 364 | - | ) -> Result<(String, Option<String>)> { | |
| 370 | + | async fn wait_for_code(listener: &tokio::net::TcpListener) -> Result<(String, Option<String>)> { | |
| 365 | 371 | use tokio::io::AsyncReadExt; | |
| 366 | 372 | loop { | |
| 367 | 373 | let (mut sock, _) = listener.accept().await.context("accept failed")?; | |
| @@ -420,7 +426,11 @@ async fn respond(sock: &mut tokio::net::TcpStream, message: &str) -> std::io::Re | |||
| 420 | 426 | ||
| 421 | 427 | /// Best-effort browser open; the URL is also printed for manual use (e.g. over SSH). | |
| 422 | 428 | fn open_browser(url: &str) { | |
| 423 | - | let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" }; | |
| 429 | + | let opener = if cfg!(target_os = "macos") { | |
| 430 | + | "open" | |
| 431 | + | } else { | |
| 432 | + | "xdg-open" | |
| 433 | + | }; | |
| 424 | 434 | let _ = std::process::Command::new(opener) | |
| 425 | 435 | .arg(url) | |
| 426 | 436 | .stdout(std::process::Stdio::null()) | |
| @@ -434,16 +444,26 @@ mod tests { | |||
| 434 | 444 | ||
| 435 | 445 | fn base_flags() -> Vec<String> { | |
| 436 | 446 | [ | |
| 437 | - | "--slug", "goingson", | |
| 438 | - | "--version", "0.4.1", | |
| 439 | - | "--target", "darwin", | |
| 440 | - | "--arch", "aarch64", | |
| 441 | - | "--artifact", "/tmp/x", | |
| 442 | - | "--email", "me@example.com", | |
| 443 | - | "--password", "pw", | |
| 444 | - | "--api-key", "ak", | |
| 445 | - | "--key", "sdk", | |
| 446 | - | "--server", "https://example.test", | |
| 447 | + | "--slug", | |
| 448 | + | "goingson", | |
| 449 | + | "--version", | |
| 450 | + | "0.4.1", | |
| 451 | + | "--target", | |
| 452 | + | "darwin", | |
| 453 | + | "--arch", | |
| 454 | + | "aarch64", | |
| 455 | + | "--artifact", | |
| 456 | + | "/tmp/x", | |
| 457 | + | "--email", | |
| 458 | + | "me@example.com", | |
| 459 | + | "--password", | |
| 460 | + | "pw", | |
| 461 | + | "--api-key", | |
| 462 | + | "ak", | |
| 463 | + | "--key", | |
| 464 | + | "sdk", | |
| 465 | + | "--server", | |
| 466 | + | "https://example.test", | |
| 447 | 467 | ] | |
| 448 | 468 | .iter() | |
| 449 | 469 | .map(|s| s.to_string()) |
| @@ -5,8 +5,8 @@ | |||
| 5 | 5 | //! parses that command, and spawns the git subprocess with its stdin/stdout/stderr piped | |
| 6 | 6 | //! through the SSH channel. | |
| 7 | 7 | ||
| 8 | - | use russh::server::Handle; | |
| 9 | 8 | use russh::ChannelId; | |
| 9 | + | use russh::server::Handle; | |
| 10 | 10 | use tokio::io::AsyncReadExt; | |
| 11 | 11 | use tokio::process::{Child, Command}; | |
| 12 | 12 |
| @@ -87,13 +87,14 @@ impl russh::server::Handler for MnwHandler { | |||
| 87 | 87 | ) -> Result<Auth, Self::Error> { | |
| 88 | 88 | // Per-IP rate limiting: reject early if threshold exceeded | |
| 89 | 89 | if let Some(addr) = self.peer_addr | |
| 90 | - | && !self.rate_limiter.check(addr.ip()) { | |
| 91 | - | tracing::warn!(peer = %addr, "auth rate limit exceeded"); | |
| 92 | - | return Ok(Auth::Reject { | |
| 93 | - | proceed_with_methods: None, | |
| 94 | - | partial_success: false, | |
| 95 | - | }); | |
| 96 | - | } | |
| 90 | + | && !self.rate_limiter.check(addr.ip()) | |
| 91 | + | { | |
| 92 | + | tracing::warn!(peer = %addr, "auth rate limit exceeded"); | |
| 93 | + | return Ok(Auth::Reject { | |
| 94 | + | proceed_with_methods: None, | |
| 95 | + | partial_success: false, | |
| 96 | + | }); | |
| 97 | + | } | |
| 97 | 98 | ||
| 98 | 99 | let fingerprint = key.fingerprint(HashAlg::Sha256).to_string(); | |
| 99 | 100 | tracing::debug!(%fingerprint, peer = ?self.peer_addr, "key offered"); | |
| @@ -137,11 +138,7 @@ impl russh::server::Handler for MnwHandler { | |||
| 137 | 138 | } | |
| 138 | 139 | } | |
| 139 | 140 | ||
| 140 | - | async fn auth_publickey( | |
| 141 | - | &mut self, | |
| 142 | - | _user: &str, | |
| 143 | - | _key: &PublicKey, | |
| 144 | - | ) -> Result<Auth, Self::Error> { | |
| 141 | + | async fn auth_publickey(&mut self, _user: &str, _key: &PublicKey) -> Result<Auth, Self::Error> { | |
| 145 | 142 | // If auth_publickey_offered accepted, the user is already stored. | |
| 146 | 143 | if self.user.is_some() { | |
| 147 | 144 | Ok(Auth::Accept) | |
| @@ -265,11 +262,7 @@ impl russh::server::Handler for MnwHandler { | |||
| 265 | 262 | }; | |
| 266 | 263 | ||
| 267 | 264 | // Take the stored channel for this ID | |
| 268 | - | let channel = self | |
| 269 | - | .channels | |
| 270 | - | .lock() | |
| 271 | - | .await | |
| 272 | - | .remove(&channel_id); | |
| 265 | + | let channel = self.channels.lock().await.remove(&channel_id); | |
| 273 | 266 | ||
| 274 | 267 | let Some(channel) = channel else { | |
| 275 | 268 | tracing::error!("no stored channel for SFTP subsystem"); | |
| @@ -335,7 +328,14 @@ impl russh::server::Handler for MnwHandler { | |||
| 335 | 328 | // gets git group ownership. Avoids systemd security | |
| 336 | 329 | // restrictions that block sudo child processes. | |
| 337 | 330 | match tokio::process::Command::new("git") | |
| 338 | - | .args(["init", "--bare", "--shared=group", "-b", "main", &auth.repo_path]) | |
| 331 | + | .args([ | |
| 332 | + | "init", | |
| 333 | + | "--bare", | |
| 334 | + | "--shared=group", | |
| 335 | + | "-b", | |
| 336 | + | "main", | |
| 337 | + | &auth.repo_path, | |
| 338 | + | ]) | |
| 339 | 339 | .stdout(std::process::Stdio::null()) | |
| 340 | 340 | .stderr(std::process::Stdio::null()) | |
| 341 | 341 | .status() | |
| @@ -355,7 +355,8 @@ impl russh::server::Handler for MnwHandler { | |||
| 355 | 355 | } | |
| 356 | 356 | Ok(s) => { | |
| 357 | 357 | tracing::error!(path = %auth.repo_path, code = ?s.code(), "git init --bare failed"); | |
| 358 | - | let msg = bytes::Bytes::from("fatal: failed to create repository\r\n"); | |
| 358 | + | let msg = | |
| 359 | + | bytes::Bytes::from("fatal: failed to create repository\r\n"); | |
| 359 | 360 | let _ = handle.extended_data(channel, 1, msg).await; | |
| 360 | 361 | let _ = handle.exit_status_request(channel, 1).await; | |
| 361 | 362 | let _ = handle.eof(channel).await; | |
| @@ -389,9 +390,7 @@ impl russh::server::Handler for MnwHandler { | |||
| 389 | 390 | } | |
| 390 | 391 | Err(e) => { | |
| 391 | 392 | tracing::error!(error = ?e, "failed to spawn git process"); | |
| 392 | - | let msg = bytes::Bytes::from( | |
| 393 | - | "fatal: internal error\r\n".to_string(), | |
| 394 | - | ); | |
| 393 | + | let msg = bytes::Bytes::from("fatal: internal error\r\n".to_string()); | |
| 395 | 394 | let _ = handle.data(channel, msg).await; | |
| 396 | 395 | let _ = handle.exit_status_request(channel, 1).await; | |
| 397 | 396 | let _ = handle.eof(channel).await; | |
| @@ -423,10 +422,22 @@ impl russh::server::Handler for MnwHandler { | |||
| 423 | 422 | let mut i = 1; | |
| 424 | 423 | while i < parts.len() { | |
| 425 | 424 | match parts[i] { | |
| 426 | - | "--filename" | "-f" if i + 1 < parts.len() => { filename = parts[i + 1].to_string(); i += 1; } | |
| 427 | - | "--project" | "-p" if i + 1 < parts.len() => { project_slug = parts[i + 1].to_string(); i += 1; } | |
| 428 | - | "--title" | "-t" if i + 1 < parts.len() => { title = parts[i + 1].to_string(); i += 1; } | |
| 429 | - | "--price" if i + 1 < parts.len() => { price_cents = parts[i + 1].parse().unwrap_or(0); i += 1; } | |
| 425 | + | "--filename" | "-f" if i + 1 < parts.len() => { | |
| 426 | + | filename = parts[i + 1].to_string(); | |
| 427 | + | i += 1; | |
| 428 | + | } | |
| 429 | + | "--project" | "-p" if i + 1 < parts.len() => { | |
| 430 | + | project_slug = parts[i + 1].to_string(); | |
| 431 | + | i += 1; | |
| 432 | + | } | |
| 433 | + | "--title" | "-t" if i + 1 < parts.len() => { | |
| 434 | + | title = parts[i + 1].to_string(); | |
| 435 | + | i += 1; | |
| 436 | + | } | |
| 437 | + | "--price" if i + 1 < parts.len() => { | |
| 438 | + | price_cents = parts[i + 1].parse().unwrap_or(0); | |
| 439 | + | i += 1; | |
| 440 | + | } | |
| 430 | 441 | _ => {} | |
| 431 | 442 | } | |
| 432 | 443 | i += 1; | |
| @@ -448,14 +459,17 @@ impl russh::server::Handler for MnwHandler { | |||
| 448 | 459 | title = staging::derive_title(&filename); | |
| 449 | 460 | } | |
| 450 | 461 | ||
| 451 | - | self.pipe_uploads.insert(channel, PipeUpload { | |
| 452 | - | user: user.clone(), | |
| 453 | - | filename, | |
| 454 | - | project_slug, | |
| 455 | - | title, | |
| 456 | - | price_cents, | |
| 457 | - | data: Vec::new(), | |
| 458 | - | }); | |
| 462 | + | self.pipe_uploads.insert( | |
| 463 | + | channel, | |
| 464 | + | PipeUpload { | |
| 465 | + | user: user.clone(), | |
| 466 | + | filename, | |
| 467 | + | project_slug, | |
| 468 | + | title, | |
| 469 | + | price_cents, | |
| 470 | + | data: Vec::new(), | |
| 471 | + | }, | |
| 472 | + | ); | |
| 459 | 473 | session.channel_success(channel)?; | |
| 460 | 474 | return Ok(()); | |
| 461 | 475 | } | |
| @@ -521,7 +535,10 @@ impl russh::server::Handler for MnwHandler { | |||
| 521 | 535 | let result = crate::commands::execute_pipe_upload(&api, upload).await; | |
| 522 | 536 | let (msg, exit_code) = match result { | |
| 523 | 537 | Ok(msg) => (msg, 0), | |
| 524 | - | Err(e) => (format!("Error: {}\r\n", crate::commands::sanitize_api_error(&e)), 1), | |
| 538 | + | Err(e) => ( | |
| 539 | + | format!("Error: {}\r\n", crate::commands::sanitize_api_error(&e)), | |
| 540 | + | 1, | |
| 541 | + | ), | |
| 525 | 542 | }; | |
| 526 | 543 | let _ = handle.data(channel, bytes::Bytes::from(msg)).await; | |
| 527 | 544 | let _ = handle.exit_status_request(channel, exit_code).await; |
| @@ -11,7 +11,7 @@ use russh_sftp::protocol::{ | |||
| 11 | 11 | Attrs, Data, File, FileAttributes, Handle, Name, OpenFlags, Status, StatusCode, Version, | |
| 12 | 12 | }; | |
| 13 | 13 | ||
| 14 | - | use crate::staging::{self, is_allowed_extension, sanitize_filename, STAGING_QUOTA_BYTES}; | |
| 14 | + | use crate::staging::{self, STAGING_QUOTA_BYTES, is_allowed_extension, sanitize_filename}; | |
| 15 | 15 | ||
| 16 | 16 | /// SFTP session handler for a single authenticated user. | |
| 17 | 17 | pub struct SftpSession { | |
| @@ -260,7 +260,9 @@ impl russh_sftp::server::Handler for SftpSession { | |||
| 260 | 260 | } | |
| 261 | 261 | ||
| 262 | 262 | if pflags.contains(OpenFlags::READ) { | |
| 263 | - | let file = tokio::fs::File::open(&file_path).await.map_err(|_| StatusCode::NoSuchFile)?; | |
| 263 | + | let file = tokio::fs::File::open(&file_path) | |
| 264 | + | .await | |
| 265 | + | .map_err(|_| StatusCode::NoSuchFile)?; | |
| 264 | 266 | let handle = self.alloc_handle(); | |
| 265 | 267 | self.open_files.insert( | |
| 266 | 268 | handle.clone(), |
| @@ -20,10 +20,7 @@ impl TerminalHandle { | |||
| 20 | 20 | /// | |
| 21 | 21 | /// The relay task forwards buffered data to the SSH session's | |
| 22 | 22 | /// channel via `session_handle.data()`. | |
| 23 | - | pub fn new( | |
| 24 | - | session_handle: russh::server::Handle, | |
| 25 | - | channel_id: russh::ChannelId, | |
| 26 | - | ) -> Self { | |
| 23 | + | pub fn new(session_handle: russh::server::Handle, channel_id: russh::ChannelId) -> Self { | |
| 27 | 24 | let (tx, mut rx) = mpsc::channel::<Vec<u8>>(64); | |
| 28 | 25 | ||
| 29 | 26 | tokio::spawn(async move { |
| @@ -55,7 +55,9 @@ pub async fn list_staged_files(dir: &Path) -> Vec<StagedFile> { | |||
| 55 | 55 | files.push(StagedFile { | |
| 56 | 56 | filename, | |
| 57 | 57 | size: metadata.len(), | |
| 58 | - | modified: metadata.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH), | |
| 58 | + | modified: metadata | |
| 59 | + | .modified() | |
| 60 | + | .unwrap_or(std::time::SystemTime::UNIX_EPOCH), | |
| 59 | 61 | classification, | |
| 60 | 62 | }); | |
| 61 | 63 | } |
| @@ -23,7 +23,10 @@ pub fn render(frame: &mut Frame, app: &App) { | |||
| 23 | 23 | }; | |
| 24 | 24 | ||
| 25 | 25 | let title = Line::from(vec![ | |
| 26 | - | Span::styled(" Makenot.work ", Style::default().add_modifier(Modifier::BOLD)), | |
| 26 | + | Span::styled( | |
| 27 | + | " Makenot.work ", | |
| 28 | + | Style::default().add_modifier(Modifier::BOLD), | |
| 29 | + | ), | |
| 27 | 30 | Span::raw(" -- "), | |
| 28 | 31 | Span::styled("Analytics", Style::default().add_modifier(Modifier::BOLD)), | |
| 29 | 32 | Span::raw(format!(" ({}) ", range_label)), | |
| @@ -41,7 +44,7 @@ pub fn render(frame: &mut Frame, app: &App) { | |||
| 41 | 44 | Constraint::Length(1), // spacer | |
| 42 | 45 | Constraint::Length(3), // stat cards | |
| 43 | 46 | Constraint::Length(1), // spacer | |
| 44 | - | Constraint::Min(6), // chart or transactions | |
| 47 | + | Constraint::Min(6), // chart or transactions | |
| 45 | 48 | Constraint::Length(1), // status | |
| 46 | 49 | Constraint::Length(1), // keybindings | |
| 47 | 50 | ]) | |
| @@ -107,17 +110,24 @@ fn render_stat_cards(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) | |||
| 107 | 110 | ( | |
| 108 | 111 | "Revenue", | |
| 109 | 112 | format::format_cents(data.as_ref().map(|d| d.current_revenue_cents).unwrap_or(0)), | |
| 110 | - | data.as_ref().map(|d| pct_change(d.current_revenue_cents, d.previous_revenue_cents)), | |
| 113 | + | data.as_ref() | |
| 114 | + | .map(|d| pct_change(d.current_revenue_cents, d.previous_revenue_cents)), | |
| 111 | 115 | ), | |
| 112 | 116 | ( | |
| 113 | 117 | "Sales", | |
| 114 | - | data.as_ref().map(|d| d.current_sales.to_string()).unwrap_or_else(|| "--".into()), | |
| 115 | - | data.as_ref().map(|d| pct_change(d.current_sales, d.previous_sales)), | |
| 118 | + | data.as_ref() | |
| 119 | + | .map(|d| d.current_sales.to_string()) | |
| 120 | + | .unwrap_or_else(|| "--".into()), | |
| 121 | + | data.as_ref() | |
| 122 | + | .map(|d| pct_change(d.current_sales, d.previous_sales)), | |
| 116 | 123 | ), | |
| 117 | 124 | ( | |
| 118 | 125 | "Followers", | |
| 119 | - | data.as_ref().map(|d| d.current_followers.to_string()).unwrap_or_else(|| "--".into()), | |
| 120 | - | data.as_ref().map(|d| pct_change(d.current_followers, d.previous_followers)), | |
| 126 | + | data.as_ref() | |
| 127 | + | .map(|d| d.current_followers.to_string()) | |
| 128 | + | .unwrap_or_else(|| "--".into()), | |
| 129 | + | data.as_ref() | |
| 130 | + | .map(|d| pct_change(d.current_followers, d.previous_followers)), | |
| 121 | 131 | ), | |
| 122 | 132 | ]; | |
| 123 | 133 | ||
| @@ -149,10 +159,10 @@ fn render_stat_cards(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) | |||
| 149 | 159 | fn render_chart_and_projects(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { | |
| 150 | 160 | let chunks = Layout::vertical([ | |
| 151 | 161 | Constraint::Length(1), // chart header | |
| 152 | - | Constraint::Min(4), // chart | |
| 162 | + | Constraint::Min(4), // chart | |
| 153 | 163 | Constraint::Length(1), // spacer | |
| 154 | 164 | Constraint::Length(1), // projects header | |
| 155 | - | Constraint::Min(2), // projects table | |
| 165 | + | Constraint::Min(2), // projects table | |
| 156 | 166 | ]) | |
| 157 | 167 | .split(area); | |
| 158 | 168 | ||
| @@ -169,7 +179,13 @@ fn render_chart_and_projects(frame: &mut Frame, app: &App, area: ratatui::layout | |||
| 169 | 179 | let empty = Paragraph::new(" No revenue data for this period."); | |
| 170 | 180 | frame.render_widget(empty, chunks[1]); | |
| 171 | 181 | } else { | |
| 172 | - | let max_val = data.buckets.iter().map(|b| b.revenue_cents).max().unwrap_or(1).max(1); | |
| 182 | + | let max_val = data | |
| 183 | + | .buckets | |
| 184 | + | .iter() | |
| 185 | + | .map(|b| b.revenue_cents) | |
| 186 | + | .max() | |
| 187 | + | .unwrap_or(1) | |
| 188 | + | .max(1); | |
| 173 | 189 | ||
| 174 | 190 | let bars: Vec<Bar> = data | |
| 175 | 191 | .buckets | |
| @@ -208,7 +224,10 @@ fn render_chart_and_projects(frame: &mut Frame, app: &App, area: ratatui::layout | |||
| 208 | 224 | // Projects header | |
| 209 | 225 | let proj_header = Paragraph::new(Line::from(vec![ | |
| 210 | 226 | Span::raw(" "), | |
| 211 | - | Span::styled("Top Projects", Style::default().add_modifier(Modifier::BOLD)), | |
| 227 | + | Span::styled( | |
| 228 | + | "Top Projects", | |
| 229 | + | Style::default().add_modifier(Modifier::BOLD), | |
| 230 | + | ), | |
| 212 | 231 | ])); | |
| 213 | 232 | frame.render_widget(proj_header, chunks[3]); | |
| 214 | 233 | ||
| @@ -238,14 +257,17 @@ fn render_chart_and_projects(frame: &mut Frame, app: &App, area: ratatui::layout | |||
| 238 | 257 | fn render_transactions(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { | |
| 239 | 258 | let chunks = Layout::vertical([ | |
| 240 | 259 | Constraint::Length(1), // header | |
| 241 | - | Constraint::Min(3), // table | |
| 260 | + | Constraint::Min(3), // table | |
| 242 | 261 | ]) | |
| 243 | 262 | .split(area); | |
| 244 | 263 | ||
| 245 | 264 | let count = app.transactions.len(); | |
| 246 | 265 | let header = Paragraph::new(Line::from(vec![ | |
| 247 | 266 | Span::raw(" "), | |
| 248 | - | Span::styled("Transactions", Style::default().add_modifier(Modifier::BOLD)), | |
| 267 | + | Span::styled( | |
| 268 | + | "Transactions", | |
| 269 | + | Style::default().add_modifier(Modifier::BOLD), | |
| 270 | + | ), | |
| 249 | 271 | if count == 0 { | |
| 250 | 272 | Span::raw("") | |
| 251 | 273 | } else { | |
| @@ -284,7 +306,13 @@ fn render_transactions(frame: &mut Frame, app: &App, area: ratatui::layout::Rect | |||
| 284 | 306 | Constraint::Length(12), | |
| 285 | 307 | ]; | |
| 286 | 308 | ||
| 287 | - | widgets::render_table(frame, chunks[1], &[" Item", "Amount", "Status", "Date"], &widths, rows); | |
| 309 | + | widgets::render_table( | |
| 310 | + | frame, | |
| 311 | + | chunks[1], | |
| 312 | + | &[" Item", "Amount", "Status", "Date"], | |
| 313 | + | &widths, | |
| 314 | + | rows, | |
| 315 | + | ); | |
| 288 | 316 | } | |
| 289 | 317 | } | |
| 290 | 318 |
| @@ -12,13 +12,13 @@ use super::widgets; | |||
| 12 | 12 | pub fn render(frame: &mut Frame, app: &App) { | |
| 13 | 13 | let area = frame.area(); | |
| 14 | 14 | ||
| 15 | - | let project_title = app | |
| 16 | - | .blog_project_title | |
| 17 | - | .as_deref() | |
| 18 | - | .unwrap_or("Blog"); | |
| 15 | + | let project_title = app.blog_project_title.as_deref().unwrap_or("Blog"); | |
| 19 | 16 | ||
| 20 | 17 | let title = Line::from(vec![ | |
| 21 | - | Span::styled(" Makenot.work ", Style::default().add_modifier(Modifier::BOLD)), | |
| 18 | + | Span::styled( | |
| 19 | + | " Makenot.work ", | |
| 20 | + | Style::default().add_modifier(Modifier::BOLD), | |
| 21 | + | ), | |
| 22 | 22 | Span::raw(" -- "), | |
| 23 | 23 | Span::styled(project_title, Style::default().add_modifier(Modifier::BOLD)), | |
| 24 | 24 | Span::raw(" -- Blog "), | |
| @@ -35,7 +35,7 @@ pub fn render(frame: &mut Frame, app: &App) { | |||
| 35 | 35 | let chunks = Layout::vertical([ | |
| 36 | 36 | Constraint::Length(1), // spacer | |
| 37 | 37 | Constraint::Length(1), // section header | |
| 38 | - | Constraint::Min(3), // post list | |
| 38 | + | Constraint::Min(3), // post list | |
| 39 | 39 | Constraint::Length(1), // status line | |
| 40 | 40 | Constraint::Length(1), // keybindings | |
| 41 | 41 | ]) | |
| @@ -139,5 +139,11 @@ fn render_post_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) | |||
| 139 | 139 | Constraint::Length(12), | |
| 140 | 140 | ]; | |
| 141 | 141 | ||
| 142 | - | widgets::render_table(frame, area, &[" Title", "Slug", "Status", "Created"], &widths, rows); | |
| 142 | + | widgets::render_table( | |
| 143 | + | frame, | |
| 144 | + | area, | |
| 145 | + | &[" Title", "Slug", "Status", "Created"], | |
| 146 | + | &widths, | |
| 147 | + | rows, | |
| 148 | + | ); | |
| 143 | 149 | } |
| @@ -13,7 +13,10 @@ pub fn render(frame: &mut Frame, app: &App) { | |||
| 13 | 13 | let area = frame.area(); | |
| 14 | 14 | ||
| 15 | 15 | let title = Line::from(vec![ | |
| 16 | - | Span::styled(" Makenot.work ", Style::default().add_modifier(Modifier::BOLD)), | |
| 16 | + | Span::styled( | |
| 17 | + | " Makenot.work ", | |
| 18 | + | Style::default().add_modifier(Modifier::BOLD), | |
| 19 | + | ), | |
| 17 | 20 | Span::raw(" -- "), | |
| 18 | 21 | Span::styled("Collections", Style::default().add_modifier(Modifier::BOLD)), | |
| 19 | 22 | Span::raw(" "), | |
| @@ -30,7 +33,7 @@ pub fn render(frame: &mut Frame, app: &App) { | |||
| 30 | 33 | let chunks = Layout::vertical([ | |
| 31 | 34 | Constraint::Length(1), // spacer | |
| 32 | 35 | Constraint::Length(1), // section header | |
| 33 | - | Constraint::Min(3), // list | |
| 36 | + | Constraint::Min(3), // list | |
| 34 | 37 | Constraint::Length(1), // status line | |
| 35 | 38 | Constraint::Length(1), // keybindings | |
| 36 | 39 | ]) | |
| @@ -40,7 +43,11 @@ pub fn render(frame: &mut Frame, app: &App) { | |||
| 40 | 43 | let header = Paragraph::new(Line::from(vec![ | |
| 41 | 44 | Span::raw(" "), | |
| 42 | 45 | Span::styled("Collections", Style::default().add_modifier(Modifier::BOLD)), | |
| 43 | - | if count == 0 { Span::raw("") } else { Span::raw(format!(" ({})", count)) }, | |
| 46 | + | if count == 0 { | |
| 47 | + | Span::raw("") | |
| 48 | + | } else { | |
| 49 | + | Span::raw(format!(" ({})", count)) | |
| 50 | + | }, | |
| 44 | 51 | ])); | |
| 45 | 52 | frame.render_widget(header, chunks[1]); | |
| 46 | 53 | ||
| @@ -48,7 +55,8 @@ pub fn render(frame: &mut Frame, app: &App) { | |||
| 48 | 55 | let loading = Paragraph::new(" Loading..."); | |
| 49 | 56 | frame.render_widget(loading, chunks[2]); | |
| 50 | 57 | } else if app.collections.is_empty() { | |
| 51 | - | let empty = Paragraph::new(" No collections. Manage collections at makenot.work/dashboard"); | |
| 58 | + | let empty = | |
| 59 | + | Paragraph::new(" No collections. Manage collections at makenot.work/dashboard"); | |
| 52 | 60 | frame.render_widget(empty, chunks[2]); | |
| 53 | 61 | } else { | |
| 54 | 62 | let rows: Vec<Row> = app | |
| @@ -74,7 +82,13 @@ pub fn render(frame: &mut Frame, app: &App) { | |||
| 74 | 82 | Constraint::Length(6), | |
| 75 | 83 | ]; | |
| 76 | 84 | ||
| 77 | - | widgets::render_table(frame, chunks[2], &[" Title", "Slug", "Status", "Items"], &widths, rows); | |
| 85 | + | widgets::render_table( | |
| 86 | + | frame, | |
| 87 | + | chunks[2], | |
| 88 | + | &[" Title", "Slug", "Status", "Items"], | |
| 89 | + | &widths, | |
| 90 | + | rows, | |
| 91 | + | ); | |
| 78 | 92 | } | |
| 79 | 93 | ||
| 80 | 94 | if let Some(ref status) = app.collections_status { |
| @@ -22,7 +22,10 @@ pub fn render(frame: &mut Frame, app: &App) { | |||
| 22 | 22 | .unwrap_or("No tier"); | |
| 23 | 23 | ||
| 24 | 24 | let title = Line::from(vec![ | |
| 25 | - | Span::styled(" Makenot.work ", Style::default().add_modifier(Modifier::BOLD)), | |
| 25 | + | Span::styled( | |
| 26 | + | " Makenot.work ", | |
| 27 | + | Style::default().add_modifier(Modifier::BOLD), | |
| 28 | + | ), | |
| 26 | 29 | Span::raw(" ── "), | |
| 27 | 30 | Span::styled( | |
| 28 | 31 | &app.user.username, | |
| @@ -46,7 +49,7 @@ pub fn render(frame: &mut Frame, app: &App) { | |||
| 46 | 49 | Constraint::Length(3), // stats bar | |
| 47 | 50 | Constraint::Length(1), // spacer | |
| 48 | 51 | Constraint::Length(1), // section header | |
| 49 | - | Constraint::Min(3), // project list | |
| 52 | + | Constraint::Min(3), // project list | |
| 50 | 53 | Constraint::Length(1), // keybindings | |
| 51 | 54 | ]) | |
| 52 | 55 | .split(inner); | |
| @@ -173,6 +176,11 @@ fn render_project_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rec | |||
| 173 | 176 | Constraint::Length(12), | |
| 174 | 177 | ]; | |
| 175 | 178 | ||
| 176 | - | widgets::render_table(frame, area, &[" Title", "Type", "Status", "Items", "Revenue"], &widths, rows); | |
| 179 | + | widgets::render_table( | |
| 180 | + | frame, | |
| 181 | + | area, | |
| 182 | + | &[" Title", "Type", "Status", "Items", "Revenue"], | |
| 183 | + | &widths, | |
| 184 | + | rows, | |
| 185 | + | ); | |
| 177 | 186 | } | |
| 178 | - |
| @@ -10,14 +10,12 @@ pub(crate) async fn handle_analytics_input( | |||
| 10 | 10 | tx: &mpsc::Sender<AppEvent>, | |
| 11 | 11 | ) { | |
| 12 | 12 | match key.code { | |
| 13 | - | KeyCode::Char('j') | KeyCode::Down | |
| 14 | - | if app.analytics_show_transactions => { | |
| 15 | - | app.move_down(screen); | |
| 16 | - | } | |
| 17 | - | KeyCode::Char('k') | KeyCode::Up | |
| 18 | - | if app.analytics_show_transactions => { | |
| 19 | - | app.move_up(screen); | |
| 20 | - | } | |
| 13 | + | KeyCode::Char('j') | KeyCode::Down if app.analytics_show_transactions => { | |
| 14 | + | app.move_down(screen); | |
| 15 | + | } | |
| 16 | + | KeyCode::Char('k') | KeyCode::Up if app.analytics_show_transactions => { | |
| 17 | + | app.move_up(screen); | |
| 18 | + | } | |
| 21 | 19 | KeyCode::Esc => { | |
| 22 | 20 | if app.analytics_show_transactions { | |
| 23 | 21 | app.analytics_show_transactions = false; |
| @@ -10,7 +10,8 @@ pub(crate) async fn handle_blog_input( | |||
| 10 | 10 | tx: &mpsc::Sender<AppEvent>, | |
| 11 | 11 | ) { | |
| 12 | 12 | // Cancel pending confirmation on any key other than the confirmation key | |
| 13 | - | if app.confirm_action.is_some() && !matches!(key.code, KeyCode::Char('d') | KeyCode::Char('D')) { | |
| 13 | + | if app.confirm_action.is_some() && !matches!(key.code, KeyCode::Char('d') | KeyCode::Char('D')) | |
| 14 | + | { | |
| 14 | 15 | app.confirm_action = None; | |
| 15 | 16 | app.blog_status = None; | |
| 16 | 17 | } | |
| @@ -20,14 +21,15 @@ pub(crate) async fn handle_blog_input( | |||
| 20 | 21 | // Ctrl+D in body step: advance to schedule | |
| 21 | 22 | if step == BlogCreateStep::Body | |
| 22 | 23 | && key.code == KeyCode::Char('d') | |
| 23 | - | && key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) | |
| 24 | + | && key | |
| 25 | + | .modifiers | |
| 26 | + | .contains(crossterm::event::KeyModifiers::CONTROL) | |
| 24 | 27 | { | |
| 25 | 28 | app.blog_create_body = app.edit_buffer.clone(); | |
| 26 | 29 | app.edit_buffer.clear(); | |
| 27 | 30 | app.blog_create_step = Some(BlogCreateStep::Schedule); | |
| 28 | - | app.blog_status = Some( | |
| 29 | - | "Schedule (YYYY-MM-DDTHH:MM:SSZ or Enter for draft):".to_string(), | |
| 30 | - | ); | |
| 31 | + | app.blog_status = | |
| 32 | + | Some("Schedule (YYYY-MM-DDTHH:MM:SSZ or Enter for draft):".to_string()); | |
| 31 | 33 | return; | |
| 32 | 34 | } | |
| 33 | 35 | ||
| @@ -45,8 +47,9 @@ pub(crate) async fn handle_blog_input( | |||
| 45 | 47 | app.blog_create_title = app.edit_buffer.clone(); | |
| 46 | 48 | app.edit_buffer.clear(); | |
| 47 | 49 | app.blog_create_step = Some(BlogCreateStep::Body); | |
| 48 | - | app.blog_status = | |
| 49 | - | Some("Body (markdown, Enter for newline, Ctrl+D when done):".to_string()); | |
| 50 | + | app.blog_status = Some( | |
| 51 | + | "Body (markdown, Enter for newline, Ctrl+D when done):".to_string(), | |
| 52 | + | ); | |
| 50 | 53 | } | |
| 51 | 54 | } | |
| 52 | 55 | BlogCreateStep::Body => { | |
| @@ -138,51 +141,51 @@ pub(crate) async fn handle_blog_input( | |||
| 138 | 141 | app.edit_buffer.clear(); | |
| 139 | 142 | app.blog_status = Some("Title: _".to_string()); | |
| 140 | 143 | } | |
| 141 | - | KeyCode::Char('d') | KeyCode::Char('D') | |
| 142 | - | if !app.blog_posts.is_empty() => { | |
| 143 | - | let idx = app.selected_index; | |
| 144 | - | if matches!(app.confirm_action, Some(ConfirmAction::DeleteBlogPost { post_idx }) if post_idx == idx) { | |
| 145 | - | // Confirmed — execute delete | |
| 146 | - | app.confirm_action = None; | |
| 147 | - | let post_id = app.blog_posts[idx].id.clone(); | |
| 148 | - | let post_title = app.blog_posts[idx].title.clone(); | |
| 149 | - | let user_id = app.user.user_id.clone(); | |
| 150 | - | tracing::info!( | |
| 151 | - | user_id = %user_id, | |
| 152 | - | post_id = %post_id, | |
| 153 | - | post_title = %post_title, | |
| 154 | - | "delete blog post confirmed" | |
| 155 | - | ); | |
| 156 | - | let api = api.clone(); | |
| 157 | - | let tx = tx.clone(); | |
| 144 | + | KeyCode::Char('d') | KeyCode::Char('D') if !app.blog_posts.is_empty() => { | |
| 145 | + | let idx = app.selected_index; | |
| 146 | + | if matches!(app.confirm_action, Some(ConfirmAction::DeleteBlogPost { post_idx }) if post_idx == idx) | |
| 147 | + | { | |
| 148 | + | // Confirmed — execute delete | |
| 149 | + | app.confirm_action = None; | |
| 150 | + | let post_id = app.blog_posts[idx].id.clone(); | |
| 151 | + | let post_title = app.blog_posts[idx].title.clone(); | |
| 152 | + | let user_id = app.user.user_id.clone(); | |
| 153 | + | tracing::info!( | |
| 154 | + | user_id = %user_id, | |
| 155 | + | post_id = %post_id, | |
| 156 | + | post_title = %post_title, | |
| 157 | + | "delete blog post confirmed" | |
| 158 | + | ); | |
| 159 | + | let api = api.clone(); | |
| 160 | + | let tx = tx.clone(); | |
| 158 | 161 | ||
| 159 | - | if let Screen::Blog(_, project_id) = &*screen { | |
| 160 | - | let project_id = project_id.clone(); | |
| 161 | - | app.blog_status = Some("Deleting...".to_string()); | |
| 162 | - | tokio::spawn(async move { | |
| 163 | - | match api.delete_blog_post(&user_id, &post_id).await { | |
| 164 | - | Ok(()) => { | |
| 165 | - | load_blog_posts(&api, &user_id, &project_id, &tx).await; | |
| 166 | - | } | |
| 167 | - | Err(e) => { | |
| 168 | - | let _ = tx | |
| 169 | - | .send(AppEvent::DataLoaded(DataPayload::GenericError { | |
| 170 | - | error: e.to_string(), | |
| 171 | - | })) | |
| 172 | - | .await; | |
| 173 | - | } | |
| 162 | + | if let Screen::Blog(_, project_id) = &*screen { | |
| 163 | + | let project_id = project_id.clone(); | |
| 164 | + | app.blog_status = Some("Deleting...".to_string()); | |
| 165 | + | tokio::spawn(async move { | |
| 166 | + | match api.delete_blog_post(&user_id, &post_id).await { | |
| 167 | + | Ok(()) => { | |
| 168 | + | load_blog_posts(&api, &user_id, &project_id, &tx).await; | |
| 174 | 169 | } | |
| 175 | - | }); | |
| 176 | - | } | |
| 177 | - | } else { | |
| 178 | - | // First press — ask for confirmation | |
| 179 | - | app.confirm_action = Some(ConfirmAction::DeleteBlogPost { post_idx: idx }); | |
| 180 | - | app.blog_status = Some(format!( | |
| 181 | - | "Delete '{}'? Press d again to confirm", | |
| 182 | - | app.blog_posts[idx].title | |
| 183 | - | )); | |
| 170 | + | Err(e) => { | |
| 171 | + | let _ = tx | |
| 172 | + | .send(AppEvent::DataLoaded(DataPayload::GenericError { | |
| 173 | + | error: e.to_string(), | |
| 174 | + | })) | |
| 175 | + | .await; | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | }); | |
| 184 | 179 | } | |
| 180 | + | } else { | |
| 181 | + | // First press — ask for confirmation | |
| 182 | + | app.confirm_action = Some(ConfirmAction::DeleteBlogPost { post_idx: idx }); | |
| 183 | + | app.blog_status = Some(format!( | |
| 184 | + | "Delete '{}'? Press d again to confirm", | |
| 185 | + | app.blog_posts[idx].title | |
| 186 | + | )); | |
| 185 | 187 | } | |
| 188 | + | } | |
| 186 | 189 | KeyCode::Char('r') | KeyCode::Char('R') => { | |
| 187 | 190 | if let Screen::Blog(_, project_id) = &*screen { | |
| 188 | 191 | app.loading = true; |
| @@ -13,22 +13,21 @@ pub(crate) async fn handle_home_input( | |||
| 13 | 13 | match key.code { | |
| 14 | 14 | KeyCode::Char('j') | KeyCode::Down => app.move_down(screen), | |
| 15 | 15 | KeyCode::Char('k') | KeyCode::Up => app.move_up(screen), | |
| 16 | - | KeyCode::Enter | |
| 17 | - | if !app.projects.is_empty() => { | |
| 18 | - | let idx = app.selected_index; | |
| 19 | - | let project_id = app.projects[idx].id.clone(); | |
| 20 | - | let user_id = app.user.user_id.clone(); | |
| 21 | - | *screen = Screen::Project(idx); | |
| 22 | - | app.items.clear(); | |
| 23 | - | app.selected_index = 0; | |
| 24 | - | app.loading = true; | |
| 16 | + | KeyCode::Enter if !app.projects.is_empty() => { | |
| 17 | + | let idx = app.selected_index; | |
| 18 | + | let project_id = app.projects[idx].id.clone(); | |
| 19 | + | let user_id = app.user.user_id.clone(); | |
| 20 | + | *screen = Screen::Project(idx); | |
| 21 | + | app.items.clear(); | |
| 22 | + | app.selected_index = 0; | |
| 23 | + | app.loading = true; | |
| 25 | 24 | ||
| 26 | - | let api = api.clone(); | |
| 27 | - | let tx = tx.clone(); | |
| 28 | - | tokio::spawn(async move { | |
| 29 | - | load_project_items(&api, &project_id, &user_id, &tx).await; | |
| 30 | - | }); | |
| 31 | - | } | |
| 25 | + | let api = api.clone(); | |
| 26 | + | let tx = tx.clone(); | |
| 27 | + | tokio::spawn(async move { | |
| 28 | + | load_project_items(&api, &project_id, &user_id, &tx).await; | |
| 29 | + | }); | |
| 30 | + | } | |
| 32 | 31 | KeyCode::Char('u') | KeyCode::Char('U') => { | |
| 33 | 32 | *screen = Screen::Upload; | |
| 34 | 33 | app.selected_index = 0; |
| @@ -22,7 +22,9 @@ pub(crate) async fn handle_item_input( | |||
| 22 | 22 | } | |
| 23 | 23 | KeyCode::Enter => { | |
| 24 | 24 | // Add the first search result as a tag | |
| 25 | - | if let (Some(tag), Some(detail)) = (app.tag_search_results.first(), &app.item_detail) { | |
| 25 | + | if let (Some(tag), Some(detail)) = | |
| 26 | + | (app.tag_search_results.first(), &app.item_detail) | |
| 27 | + | { | |
| 26 | 28 | let tag_id = tag.id.clone(); | |
| 27 | 29 | let item_id = detail.id.clone(); | |
| 28 | 30 | let user_id = app.user.user_id.clone(); | |
| @@ -33,16 +35,25 @@ pub(crate) async fn handle_item_input( | |||
| 33 | 35 | match api.add_item_tag(&user_id, &item_id, &tag_id).await { | |
| 34 | 36 | Ok(()) => { | |
| 35 | 37 | // Reload tags | |
| 36 | - | let tags = api.list_item_tags(&user_id, &item_id).await.unwrap_or_default(); | |
| 37 | - | let _ = tx.send(AppEvent::DataLoaded(DataPayload::ItemTags { tags })).await; | |
| 38 | - | let _ = tx.send(AppEvent::DataLoaded(DataPayload::ItemActionError { | |
| 39 | - | error: format!("Added tag: {}", tag_name), // Reuse error channel for status | |
| 40 | - | })).await; | |
| 38 | + | let tags = api | |
| 39 | + | .list_item_tags(&user_id, &item_id) | |
| 40 | + | .await | |
| 41 | + | .unwrap_or_default(); | |
| 42 | + | let _ = tx | |
| 43 | + | .send(AppEvent::DataLoaded(DataPayload::ItemTags { tags })) | |
| 44 | + | .await; | |
| 45 | + | let _ = tx | |
| 46 | + | .send(AppEvent::DataLoaded(DataPayload::ItemActionError { | |
| 47 | + | error: format!("Added tag: {}", tag_name), // Reuse error channel for status | |
| 48 | + | })) | |
| 49 | + | .await; | |
| 41 | 50 | } | |
| 42 | 51 | Err(e) => { | |
| 43 | - | let _ = tx.send(AppEvent::DataLoaded(DataPayload::ItemActionError { | |
| 44 | - | error: e.to_string(), | |
| 45 | - | })).await; | |
| 52 | + | let _ = tx | |
| 53 | + | .send(AppEvent::DataLoaded(DataPayload::ItemActionError { | |
| 54 | + | error: e.to_string(), | |
| 55 | + | })) | |
| 56 | + | .await; | |
| 46 | 57 | } | |
| 47 | 58 | } | |
| 48 | 59 | }); | |
| @@ -58,7 +69,9 @@ pub(crate) async fn handle_item_input( | |||
| 58 | 69 | let api = api.clone(); | |
| 59 | 70 | let query = app.edit_buffer.clone(); | |
| 60 | 71 | let tx = tx.clone(); | |
| 61 | - | tokio::spawn(async move { search_tags(&api, &query, &tx).await; }); | |
| 72 | + | tokio::spawn(async move { | |
| 73 | + | search_tags(&api, &query, &tx).await; | |
| 74 | + | }); | |
| 62 | 75 | } else { | |
| 63 | 76 | app.tag_search_results.clear(); | |
| 64 | 77 | } | |
| @@ -69,9 +82,13 @@ pub(crate) async fn handle_item_input( | |||
| 69 | 82 | let api = api.clone(); | |
| 70 | 83 | let query = app.edit_buffer.clone(); | |
| 71 | 84 | let tx = tx.clone(); | |
| 72 | - | tokio::spawn(async move { search_tags(&api, &query, &tx).await; }); | |
| 85 | + | tokio::spawn(async move { | |
| 86 | + | search_tags(&api, &query, &tx).await; | |
| 87 | + | }); | |
| 73 | 88 | } | |
| 74 | - | let results_preview: String = app.tag_search_results.iter() | |
| 89 | + | let results_preview: String = app | |
| 90 | + | .tag_search_results | |
| 91 | + | .iter() | |
| 75 | 92 | .take(3) | |
| 76 | 93 | .map(|t| t.name.as_str()) | |
| 77 | 94 | .collect::<Vec<_>>() | |
| @@ -79,7 +96,11 @@ pub(crate) async fn handle_item_input( | |||
| 79 | 96 | app.item_status = Some(format!( | |
| 80 | 97 | "Tag: {}_ {}", | |
| 81 | 98 | app.edit_buffer, | |
| 82 | - | if results_preview.is_empty() { String::new() } else { format!("[{}]", results_preview) }, | |
| 99 | + | if results_preview.is_empty() { | |
| 100 | + | String::new() | |
| 101 | + | } else { | |
| 102 | + | format!("[{}]", results_preview) | |
| 103 | + | }, | |
| 83 | 104 | )); | |
| 84 | 105 | } | |
| 85 | 106 | _ => {} | |
| @@ -88,7 +109,8 @@ pub(crate) async fn handle_item_input( | |||
| 88 | 109 | } | |
| 89 | 110 | ||
| 90 | 111 | // Cancel pending confirmation on any key other than the confirmation key | |
| 91 | - | if app.confirm_action.is_some() && !matches!(key.code, KeyCode::Char('d') | KeyCode::Char('D')) { | |
| 112 | + | if app.confirm_action.is_some() && !matches!(key.code, KeyCode::Char('d') | KeyCode::Char('D')) | |
| 113 | + | { | |
| 92 | 114 | app.confirm_action = None; | |
| 93 | 115 | app.item_status = None; | |
| 94 | 116 | } | |
| @@ -115,7 +137,14 @@ pub(crate) async fn handle_item_input( | |||
| 115 | 137 | let title = buffer; | |
| 116 | 138 | tokio::spawn(async move { | |
| 117 | 139 | match api | |
| 118 | - | .update_item(&user_id, &item_id, Some(&title), None, None, None) | |
| 140 | + | .update_item( | |
| 141 | + | &user_id, | |
| 142 | + | &item_id, | |
| 143 | + | Some(&title), | |
| 144 | + | None, | |
| 145 | + | None, | |
| 146 | + | None, | |
| 147 | + | ) | |
| 119 | 148 | .await | |
| 120 | 149 | { | |
| 121 | 150 | Ok(d) => { | |
| @@ -161,9 +190,9 @@ pub(crate) async fn handle_item_input( | |||
| 161 | 190 | { | |
| 162 | 191 | Ok(d) => { | |
| 163 | 192 | let _ = tx | |
| 164 | - | .send(AppEvent::DataLoaded( | |
| 165 | - | DataPayload::ItemUpdated { detail: d }, | |
| 166 | - | )) | |
| 193 | + | .send(AppEvent::DataLoaded(DataPayload::ItemUpdated { | |
| 194 | + | detail: d, | |
| 195 | + | })) | |
| 167 | 196 | .await; | |
| 168 | 197 | } | |
| 169 | 198 | Err(e) => { | |
| @@ -187,9 +216,9 @@ pub(crate) async fn handle_item_input( | |||
| 187 | 216 | { | |
| 188 | 217 | Ok(d) => { | |
| 189 | 218 | let _ = tx | |
| 190 | - | .send(AppEvent::DataLoaded( | |
| 191 | - | DataPayload::ItemUpdated { detail: d }, | |
| 192 | - | )) | |
| 219 | + | .send(AppEvent::DataLoaded(DataPayload::ItemUpdated { | |
| 220 | + | detail: d, | |
| 221 | + | })) | |
| 193 | 222 | .await; | |
| 194 | 223 | } | |
| 195 | 224 | Err(e) => { | |
| @@ -277,9 +306,7 @@ pub(crate) async fn handle_item_input( | |||
| 277 | 306 | match api.publish_item(&user_id, &item_id).await { | |
| 278 | 307 | Ok(d) => { | |
| 279 | 308 | let _ = tx | |
| 280 | - | .send(AppEvent::DataLoaded(DataPayload::ItemUpdated { | |
| 281 | - | detail: d, | |
| 282 | - | })) | |
| 309 | + | .send(AppEvent::DataLoaded(DataPayload::ItemUpdated { detail: d })) | |
| 283 | 310 | .await; | |
| 284 | 311 | } | |
| 285 | 312 | Err(e) => { | |
| @@ -306,9 +333,7 @@ pub(crate) async fn handle_item_input( | |||
| 306 | 333 | match api.unpublish_item(&user_id, &item_id).await { | |
| 307 | 334 | Ok(d) => { | |
| 308 | 335 | let _ = tx | |
| 309 | - | .send(AppEvent::DataLoaded(DataPayload::ItemUpdated { | |
| 310 | - | detail: d, | |
| 311 | - | })) | |
| 336 | + | .send(AppEvent::DataLoaded(DataPayload::ItemUpdated { detail: d })) | |
| 312 | 337 | .await; | |
| 313 | 338 | } | |
| 314 | 339 | Err(e) => { | |
| @@ -365,13 +390,13 @@ pub(crate) async fn handle_item_input( | |||
| 365 | 390 | } | |
| 366 | 391 | } | |
| 367 | 392 | } | |
| 368 | - | KeyCode::Char('t') | KeyCode::Char('T') | |
| 369 | - | if app.item_detail.is_some() => { | |
| 370 | - | app.tag_searching = true; | |
| 371 | - | app.edit_buffer.clear(); | |
| 372 | - | app.tag_search_results.clear(); | |
| 373 | - | app.item_status = Some("Tag: type to search, Enter to add first result, Esc to cancel".to_string()); | |
| 374 | - | } | |
| 393 | + | KeyCode::Char('t') | KeyCode::Char('T') if app.item_detail.is_some() => { | |
| 394 | + | app.tag_searching = true; | |
| 395 | + | app.edit_buffer.clear(); | |
| 396 | + | app.tag_search_results.clear(); | |
| 397 | + | app.item_status = | |
| 398 | + | Some("Tag: type to search, Enter to add first result, Esc to cancel".to_string()); | |
| 399 | + | } | |
| 375 | 400 | KeyCode::Char('l') | KeyCode::Char('L') => { | |
| 376 | 401 | // Open license keys screen | |
| 377 | 402 | if let Screen::Item(project_idx, item_id) = &*screen { |
| @@ -10,7 +10,8 @@ pub(crate) async fn handle_keys_input( | |||
| 10 | 10 | tx: &mpsc::Sender<AppEvent>, | |
| 11 | 11 | ) { | |
| 12 | 12 | // Cancel pending confirmation on any key other than the confirmation key | |
| 13 | - | if app.confirm_action.is_some() && !matches!(key.code, KeyCode::Char('x') | KeyCode::Char('X')) { | |
| 13 | + | if app.confirm_action.is_some() && !matches!(key.code, KeyCode::Char('x') | KeyCode::Char('X')) | |
| 14 | + | { | |
| 14 | 15 | app.confirm_action = None; | |
| 15 | 16 | app.keys_status = None; | |
| 16 | 17 | } | |
| @@ -65,51 +66,51 @@ pub(crate) async fn handle_keys_input( | |||
| 65 | 66 | }); | |
| 66 | 67 | } | |
| 67 | 68 | } | |
| 68 | - | KeyCode::Char('x') | KeyCode::Char('X') | |
| 69 | - | if !app.license_keys.is_empty() => { | |
| 70 | - | let idx = app.selected_index; | |
| 71 | - | if matches!(app.confirm_action, Some(ConfirmAction::RevokeLicenseKey { key_idx }) if key_idx == idx) { | |
| 72 | - | // Confirmed — execute revoke | |
| 73 | - | app.confirm_action = None; | |
| 74 | - | let key_id = app.license_keys[idx].id.clone(); | |
| 75 | - | let key_code = app.license_keys[idx].key_code.clone(); | |
| 69 | + | KeyCode::Char('x') | KeyCode::Char('X') if !app.license_keys.is_empty() => { | |
| 70 | + | let idx = app.selected_index; | |
| 71 | + | if matches!(app.confirm_action, Some(ConfirmAction::RevokeLicenseKey { key_idx }) if key_idx == idx) | |
| 72 | + | { | |
| 73 | + | // Confirmed — execute revoke | |
| 74 | + | app.confirm_action = None; | |
| 75 | + | let key_id = app.license_keys[idx].id.clone(); | |
| 76 | + | let key_code = app.license_keys[idx].key_code.clone(); | |
| 77 | + | let user_id = app.user.user_id.clone(); | |
| 78 | + | tracing::info!( | |
| 79 | + | user_id = %user_id, | |
| 80 | + | key_id = %key_id, | |
| 81 | + | key_code = %key_code, | |
| 82 | + | "revoke license key confirmed" | |
| 83 | + | ); | |
| 84 | + | if let Screen::Keys(_, item_id) = &*screen { | |
| 85 | + | let item_id = item_id.clone(); | |
| 86 | + | let api = api.clone(); | |
| 76 | 87 | let user_id = app.user.user_id.clone(); | |
| 77 | - | tracing::info!( | |
| 78 | - | user_id = %user_id, | |
| 79 | - | key_id = %key_id, | |
| 80 | - | key_code = %key_code, | |
| 81 | - | "revoke license key confirmed" | |
| 82 | - | ); | |
| 83 | - | if let Screen::Keys(_, item_id) = &*screen { | |
| 84 | - | let item_id = item_id.clone(); | |
| 85 | - | let api = api.clone(); | |
| 86 | - | let user_id = app.user.user_id.clone(); | |
| 87 | - | let tx = tx.clone(); | |
| 88 | - | app.keys_status = Some("Revoking...".to_string()); | |
| 89 | - | tokio::spawn(async move { | |
| 90 | - | match api.revoke_license_key(&user_id, &key_id).await { | |
| 91 | - | Ok(()) => { | |
| 92 | - | load_license_keys(&api, &user_id, &item_id, &tx).await; | |
| 93 | - | } | |
| 94 | - | Err(e) => { | |
| 95 | - | let _ = tx | |
| 96 | - | .send(AppEvent::DataLoaded(DataPayload::GenericError { | |
| 97 | - | error: e.to_string(), | |
| 98 | - | })) | |
| 99 | - | .await; | |
| 100 | - | } | |
| 88 | + | let tx = tx.clone(); | |
| 89 | + | app.keys_status = Some("Revoking...".to_string()); | |
| 90 | + | tokio::spawn(async move { | |
| 91 | + | match api.revoke_license_key(&user_id, &key_id).await { | |
| 92 | + | Ok(()) => { | |
| 93 | + | load_license_keys(&api, &user_id, &item_id, &tx).await; | |
| 101 | 94 | } | |
| 102 | - | }); | |
| 103 | - | } | |
| 104 | - | } else { | |
| 105 | - | // First press — ask for confirmation | |
| 106 | - | app.confirm_action = Some(ConfirmAction::RevokeLicenseKey { key_idx: idx }); | |
| 107 | - | app.keys_status = Some(format!( | |
| 108 | - | "Revoke '{}'? Press x again to confirm", | |
| 109 | - | app.license_keys[idx].key_code | |
| 110 | - | )); | |
| 95 | + | Err(e) => { | |
| 96 | + | let _ = tx | |
| 97 | + | .send(AppEvent::DataLoaded(DataPayload::GenericError { | |
| 98 | + | error: e.to_string(), | |
| 99 | + | })) | |
| 100 | + | .await; | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | }); | |
| 111 | 104 | } | |
| 105 | + | } else { | |
| 106 | + | // First press — ask for confirmation | |
| 107 | + | app.confirm_action = Some(ConfirmAction::RevokeLicenseKey { key_idx: idx }); | |
| 108 | + | app.keys_status = Some(format!( | |
| 109 | + | "Revoke '{}'? Press x again to confirm", | |
| 110 | + | app.license_keys[idx].key_code | |
| 111 | + | )); | |
| 112 | 112 | } | |
| 113 | + | } | |
| 113 | 114 | KeyCode::Char('r') | KeyCode::Char('R') => { | |
| 114 | 115 | if let Screen::Keys(_, item_id) = &*screen { | |
| 115 | 116 | app.loading = true; |
| @@ -9,30 +9,29 @@ use crate::api::MnwApiClient; | |||
| 9 | 9 | ||
| 10 | 10 | use super::loading::*; | |
| 11 | 11 | use super::{ | |
| 12 | - | App, AppEvent, BlogCreateStep, ConfirmAction, DataPayload, EditField, PromoCreateStep, | |
| 13 | - | Screen, | |
| 12 | + | App, AppEvent, BlogCreateStep, ConfirmAction, DataPayload, EditField, PromoCreateStep, Screen, | |
| 14 | 13 | }; | |
| 15 | 14 | ||
| 15 | + | mod analytics; | |
| 16 | + | mod blog; | |
| 17 | + | mod collections; | |
| 16 | 18 | mod home; | |
| 17 | - | mod project; | |
| 18 | - | mod upload; | |
| 19 | 19 | mod item; | |
| 20 | - | mod blog; | |
| 21 | - | mod promo; | |
| 22 | 20 | mod keys; | |
| 23 | - | mod analytics; | |
| 21 | + | mod project; | |
| 22 | + | mod promo; | |
| 24 | 23 | mod settings; | |
| 25 | - | mod collections; | |
| 26 | 24 | mod tiers; | |
| 25 | + | mod upload; | |
| 27 | 26 | ||
| 27 | + | pub(crate) use analytics::*; | |
| 28 | + | pub(crate) use blog::*; | |
| 29 | + | pub(crate) use collections::*; | |
| 28 | 30 | pub(crate) use home::*; | |
| 29 | - | pub(crate) use project::*; | |
| 30 | - | pub(crate) use upload::*; | |
| 31 | 31 | pub(crate) use item::*; | |
| 32 | - | pub(crate) use blog::*; | |
| 33 | - | pub(crate) use promo::*; | |
| 34 | 32 | pub(crate) use keys::*; | |
| 35 | - | pub(crate) use analytics::*; | |
| 33 | + | pub(crate) use project::*; | |
| 34 | + | pub(crate) use promo::*; | |
| 36 | 35 | pub(crate) use settings::*; | |
| 37 | - | pub(crate) use collections::*; | |
| 38 | 36 | pub(crate) use tiers::*; | |
| 37 | + | pub(crate) use upload::*; |