Skip to content

Commit f8676eb

Browse files
authored
Merge pull request #7 from RandomCodeSpace/worktree-tui-redesign
feat: redesign TUI into themed, settings-driven productivity app
2 parents 0faf139 + 55634b4 commit f8676eb

18 files changed

Lines changed: 6180 additions & 2345 deletions

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ description = "Rust/Ratatui GitLab personal kanban TUI"
88

99
[dependencies]
1010
ratatui = "0.30"
11-
crossterm = "0.29"
11+
crossterm = { version = "0.29", features = ["bracketed-paste"] }
1212
serde = { version = "1.0.228", features = ["derive"] }
1313
serde_json = "1.0.150"
1414
thiserror = "2.0.18"

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@
22

33
Lane is a Rust/Ratatui terminal UI for a personal GitLab-backed kanban board.
44

5+
## Configuration
6+
7+
Environment variables are optional. Press `Ctrl+,` inside the app to open
8+
Settings (GitLab URL/project/token, AI provider, appearance, confirmations);
9+
values persist as `settings.json` in the OS config directory
10+
(`$XDG_CONFIG_HOME/lane-ratatui` or `~/.config/lane-ratatui`). Environment
11+
variables (`LANE_GITLAB_URL`, `LANE_GITLAB_TOKEN`, `LANE_PERSONAL_PROJECT_ID`,
12+
`LANE_LLM_*`) still work and take precedence over saved settings.
13+
14+
Press `?` inside the app for the full keyboard reference (`/` search,
15+
`n` new issue, `g` generate stories, `s` sync, `hjkl`/arrows to navigate).
16+
517
## npm Package
618

719
`@ossrandom/lane` ships prebuilt binaries. Installing and running the package

REDESIGN_PLAN.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Lane TUI Redesign — Plan & Status
2+
3+
Target: premium dark productivity TUI (Linear/Raycast-style) per design mock.
4+
Backend (gitlab/llm/jobs/sync/labels/sanitize/model) stays intact.
5+
6+
Status legend: [ ] todo · [~] in progress · [x] done
7+
8+
**STATUS: COMPLETE** — all phases landed; 262 tests green, `cargo fmt --check` clean,
9+
clippy: only 5 pre-existing warnings in untouched code (sanitize.rs, jobs.rs).
10+
11+
## Phase 1 — Theme + app modes + component structure
12+
- [x] `src/theme.rs`: centralized palette (BG #0B1117, SURFACE #111827, elevated #162231, border #263445, accent #7C5CFF, semantic colors), style helpers, spinner frames
13+
- [x] `src/lib.rs` rewrite: state + logic only; new overlays Settings/Help/Confirm; StatusKind; busy tracking; search filter; story tabs; delete+confirm; settings form wiring (+new logic tests)
14+
- [x] `src/ui/mod.rs`: render dispatch, modal frame, buttons, shortcut bar done; ported render tests pending integration
15+
16+
## Phase 2 — Settings persistence
17+
- [x] `src/settings.rs` (subagent): Settings struct, JSON persistence in config dir, validation, SettingsForm model, apply_to_env merge (env wins)
18+
- [x] `main.rs`: config loads env → settings.json fallback; live reload after save (config_dirty)
19+
- [x] Missing config guides to Settings (Ctrl+,)
20+
21+
## Phase 3 — Async status/toast/spinner
22+
- [x] busy label per job in main loop (subagent); spinner + colored status in status bar (subagent)
23+
- [x] jobs.rs: status kinds on results (success/error arms)
24+
25+
## Phase 4 — Board polish
26+
- [x] `src/ui/board.rs` (subagent): column accents, label pills, focus ring, empty states, appearance settings, search-filtered rendering
27+
- [x] `src/ui/chrome.rs` (subagent): topbar (brand, connection dot, project@user, search box, buttons), context-aware footer
28+
29+
## Phase 5 — Modal redesign
30+
- [x] `src/ui/modals.rs` (subagent): issue editor + notes + palette on shared modal chrome
31+
- [x] `src/ui/confirm.rs` (subagent): confirm dialog (destructive actions)
32+
- [x] `src/ui/settings_ui.rs` (subagent): tabbed settings modal, masked secrets, inline validation, save feedback
33+
34+
## Phase 6 — Markdown + Generate Stories
35+
- [x] `src/ui/markdown.rs` (subagent): themed renderer, links underlined, italic, ordered lists, tests
36+
- [x] `src/ui/stories.rs` (subagent): Edit/Preview/Generated tabs, scrolling source, spinner while generating, review/select/edit/create flow
37+
38+
## Phase 7 — Help, keys, tests, lint
39+
- [x] `src/ui/help.rs` (subagent): `?` overlay with grouped shortcuts
40+
- [x] `main.rs` keys (subagent): h/j/k/l, `/` search, `e` edit, `?` help, Ctrl+, settings, s sync (r kept); search input routing; settings-file config fallback; live reload; busy labels
41+
- [x] Port/update render tests to new theme; new tests: settings, search nav, confirm flow, story tabs, help
42+
- [x] cargo fmt + cargo test + clippy clean
43+
- [x] Commit, push, draft PR
44+
45+
## Acceptance criteria mapping
46+
- Env vars optional → Phase 2
47+
- Settings persist + guide → Phase 2
48+
- Async feedback everywhere → Phase 3
49+
- Markdown/story flow → Phase 6
50+
- Navigation/modals/theme → Phases 1/4/5/7
51+
- Backend intact → no changes beyond status kinds

src/jobs.rs

Lines changed: 46 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -832,7 +832,10 @@ pub fn apply_job_result(app: &mut crate::App, result: JobResult) {
832832
app.ai_job_in_flight = false;
833833
let count = stories.len();
834834
app.set_generated_stories(stories);
835-
app.story_status = Some(format!("Generated {count} stories"));
835+
app.set_status(
836+
crate::StatusKind::Success,
837+
format!("Generated {count} stories"),
838+
);
836839
}
837840
}
838841
JobResult::Connected(Ok(state)) => {
@@ -842,6 +845,7 @@ pub fn apply_job_result(app: &mut crate::App, result: JobResult) {
842845
app.open_assigned_set.clear();
843846
app.has_synced = false;
844847
app.replace_issue_records(&state.issues);
848+
app.status_kind = crate::StatusKind::Success;
845849
app.story_status = Some(format!(
846850
"Connected as {} on {}",
847851
app.connected_user
@@ -855,6 +859,7 @@ pub fn apply_job_result(app: &mut crate::App, result: JobResult) {
855859
));
856860
}
857861
JobResult::BootstrappedLabels(Ok(summary)) => {
862+
app.status_kind = crate::StatusKind::Success;
858863
app.story_status = Some(format!(
859864
"Bootstrap: {} created, {} already present",
860865
summary.created.len(),
@@ -870,27 +875,30 @@ pub fn apply_job_result(app: &mut crate::App, result: JobResult) {
870875
app.close_overlay();
871876
}
872877
}
873-
app.story_status = Some(format!("Created issue #{iid}"));
878+
app.set_status(crate::StatusKind::Success, format!("Created issue #{iid}"));
874879
}
875880
JobResult::IssueUpdated(Ok(issue)) => {
876881
let iid = issue.iid;
877882
app.upsert_issue_record(&issue);
878883
if app.editor_submit_in_flight {
879884
app.editor_submit_in_flight = false;
880-
if app.overlay == Some(crate::Overlay::Details) {
885+
if app.overlay == Some(crate::Overlay::EditIssue) {
881886
app.close_overlay();
882887
}
883888
}
884-
app.story_status = Some(format!("Saved issue #{iid}"));
889+
app.set_status(crate::StatusKind::Success, format!("Saved issue #{iid}"));
885890
}
886891
JobResult::IssueDeleted(Ok(iid)) => {
887892
app.remove_issue(iid);
888-
app.story_status = Some(format!("Deleted issue #{iid}"));
893+
app.set_status(crate::StatusKind::Success, format!("Deleted issue #{iid}"));
889894
}
890895
JobResult::NotesLoaded(Ok((iid, request_id, notes))) => {
891896
let count = notes.len();
892897
if app.set_notes_for_issue(iid, request_id, notes) {
893-
app.story_status = Some(format!("Loaded {count} notes for #{iid}"));
898+
app.set_status(
899+
crate::StatusKind::Success,
900+
format!("Loaded {count} notes for #{iid}"),
901+
);
894902
}
895903
}
896904
JobResult::NoteCreated(Ok((iid, request_id, body, note))) => {
@@ -907,7 +915,10 @@ pub fn apply_job_result(app: &mut crate::App, result: JobResult) {
907915
if matches_pending {
908916
app.note_submit_pending = None;
909917
}
910-
app.story_status = Some(format!("Created note on #{iid}"));
918+
app.set_status(
919+
crate::StatusKind::Success,
920+
format!("Created note on #{iid}"),
921+
);
911922
}
912923
}
913924
JobResult::NoteCreated(Err(err)) => {
@@ -919,7 +930,7 @@ pub fn apply_job_result(app: &mut crate::App, result: JobResult) {
919930
});
920931
if matches_pending {
921932
app.note_submit_pending = None;
922-
app.story_status = Some(err.error);
933+
app.set_status(crate::StatusKind::Error, err.error);
923934
}
924935
}
925936
JobResult::SourceSnapshotPulled(Ok(iid)) => {
@@ -932,15 +943,21 @@ pub fn apply_job_result(app: &mut crate::App, result: JobResult) {
932943
request_id: app.notes_session_id,
933944
});
934945
}
935-
app.story_status = Some(format!("Pulled source snapshot into #{iid}"));
946+
app.set_status(
947+
crate::StatusKind::Success,
948+
format!("Pulled source snapshot into #{iid}"),
949+
);
936950
}
937951
JobResult::GeneratedStories {
938952
generation_id,
939953
result: Err(err),
940954
} => {
941955
if app.ai_generation_id == generation_id {
942956
app.ai_job_in_flight = false;
943-
app.story_status = Some(format!("Story generation failed: {err}"));
957+
app.set_status(
958+
crate::StatusKind::Error,
959+
format!("Story generation failed: {err}"),
960+
);
944961
}
945962
}
946963
JobResult::ImprovedDescription {
@@ -969,6 +986,8 @@ pub fn apply_job_result(app: &mut crate::App, result: JobResult) {
969986
if app.ai_generation_id == generation_id {
970987
app.ai_job_in_flight = false;
971988
app.editor_labels = labels;
989+
app.editor_labels_input = app.editor_labels.join(", ");
990+
app.status_kind = crate::StatusKind::Success;
972991
app.story_status = Some(format!(
973992
"Suggested labels: {}",
974993
app.editor_labels.join(", ")
@@ -981,15 +1000,24 @@ pub fn apply_job_result(app: &mut crate::App, result: JobResult) {
9811000
let skipped = sync.skipped.len();
9821001
app.open_assigned_set = sync.open_assigned_set;
9831002
app.has_synced = true;
1003+
app.last_synced = Some(std::time::Instant::now());
9841004
app.replace_issue_records(&sync.issues);
985-
app.story_status = Some(format!("Forked {forked}, skipped {skipped}, total {total}"));
1005+
app.set_status(
1006+
crate::StatusKind::Success,
1007+
format!("Forked {forked}, skipped {skipped}, total {total}"),
1008+
);
9861009
}
9871010
JobResult::CreatedStories(result) => {
9881011
app.story_create_in_flight = false;
9891012
let created = result.created;
9901013
let failed = result.failed;
9911014
app.append_issue_records(&created);
9921015
app.deselect_created_story_drafts(&created, &failed);
1016+
app.status_kind = if failed.is_empty() {
1017+
crate::StatusKind::Success
1018+
} else {
1019+
crate::StatusKind::Error
1020+
};
9931021
app.story_status = Some(match (created.len(), failed.len()) {
9941022
(created_count, 0) => format!("Created {created_count} stories"),
9951023
(0, 1) => failed[0].error.clone(),
@@ -1001,15 +1029,15 @@ pub fn apply_job_result(app: &mut crate::App, result: JobResult) {
10011029
}
10021030
JobResult::IssueCreated(Err(err)) | JobResult::IssueUpdated(Err(err)) => {
10031031
app.editor_submit_in_flight = false;
1004-
app.story_status = Some(err);
1032+
app.set_status(crate::StatusKind::Error, err);
10051033
}
10061034
JobResult::Synced(Err(err))
10071035
| JobResult::IssueDeleted(Err(err))
10081036
| JobResult::Connected(Err(err))
10091037
| JobResult::BootstrappedLabels(Err(err))
10101038
| JobResult::NotesLoaded(Err(err))
10111039
| JobResult::SourceSnapshotPulled(Err(err)) => {
1012-
app.story_status = Some(err);
1040+
app.set_status(crate::StatusKind::Error, err);
10131041
}
10141042
JobResult::ImprovedDescription {
10151043
generation_id,
@@ -1025,7 +1053,7 @@ pub fn apply_job_result(app: &mut crate::App, result: JobResult) {
10251053
} => {
10261054
if app.ai_generation_id == generation_id {
10271055
app.ai_job_in_flight = false;
1028-
app.story_status = Some(err);
1056+
app.set_status(crate::StatusKind::Error, err);
10291057
}
10301058
}
10311059
}
@@ -2551,7 +2579,7 @@ mod tests {
25512579
#[test]
25522580
fn issue_updated_success_closes_pending_details_modal() {
25532581
let mut app = crate::App::demo();
2554-
app.open_details();
2582+
app.open_edit_issue();
25552583
app.editor_submit_in_flight = true;
25562584

25572585
apply_job_result(
@@ -2570,20 +2598,20 @@ mod tests {
25702598
JobResult::IssueUpdated(Err("update failed".into())),
25712599
] {
25722600
let mut app = crate::App::demo();
2573-
app.open_details();
2601+
app.open_edit_issue();
25742602
app.editor_submit_in_flight = true;
25752603

25762604
apply_job_result(&mut app, result);
25772605

2578-
assert_eq!(app.overlay, Some(crate::Overlay::Details));
2606+
assert_eq!(app.overlay, Some(crate::Overlay::EditIssue));
25792607
assert!(!app.editor_submit_in_flight);
25802608
}
25812609
}
25822610

25832611
#[test]
25842612
fn ai_editor_stale_result_does_not_overwrite_after_cancel() {
25852613
let mut app = crate::App::demo();
2586-
app.open_details();
2614+
app.open_edit_issue();
25872615
app.editor_description = "keep me".into();
25882616
app.request_improve_description();
25892617
let generation_id = app.ai_generation_id;

0 commit comments

Comments
 (0)