Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ description = "Rust/Ratatui GitLab personal kanban TUI"

[dependencies]
ratatui = "0.30"
crossterm = "0.29"
crossterm = { version = "0.29", features = ["bracketed-paste"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
thiserror = "2.0.18"
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

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

## Configuration

Environment variables are optional. Press `Ctrl+,` inside the app to open
Settings (GitLab URL/project/token, AI provider, appearance, confirmations);
values persist as `settings.json` in the OS config directory
(`$XDG_CONFIG_HOME/lane-ratatui` or `~/.config/lane-ratatui`). Environment
variables (`LANE_GITLAB_URL`, `LANE_GITLAB_TOKEN`, `LANE_PERSONAL_PROJECT_ID`,
`LANE_LLM_*`) still work and take precedence over saved settings.

Press `?` inside the app for the full keyboard reference (`/` search,
`n` new issue, `g` generate stories, `s` sync, `hjkl`/arrows to navigate).

## npm Package

`@ossrandom/lane` ships prebuilt binaries. Installing and running the package
Expand Down
51 changes: 51 additions & 0 deletions REDESIGN_PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Lane TUI Redesign — Plan & Status

Target: premium dark productivity TUI (Linear/Raycast-style) per design mock.
Backend (gitlab/llm/jobs/sync/labels/sanitize/model) stays intact.

Status legend: [ ] todo · [~] in progress · [x] done

**STATUS: COMPLETE** — all phases landed; 262 tests green, `cargo fmt --check` clean,
clippy: only 5 pre-existing warnings in untouched code (sanitize.rs, jobs.rs).

## Phase 1 — Theme + app modes + component structure
- [x] `src/theme.rs`: centralized palette (BG #0B1117, SURFACE #111827, elevated #162231, border #263445, accent #7C5CFF, semantic colors), style helpers, spinner frames
- [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)
- [x] `src/ui/mod.rs`: render dispatch, modal frame, buttons, shortcut bar done; ported render tests pending integration

## Phase 2 — Settings persistence
- [x] `src/settings.rs` (subagent): Settings struct, JSON persistence in config dir, validation, SettingsForm model, apply_to_env merge (env wins)
- [x] `main.rs`: config loads env → settings.json fallback; live reload after save (config_dirty)
- [x] Missing config guides to Settings (Ctrl+,)

## Phase 3 — Async status/toast/spinner
- [x] busy label per job in main loop (subagent); spinner + colored status in status bar (subagent)
- [x] jobs.rs: status kinds on results (success/error arms)

## Phase 4 — Board polish
- [x] `src/ui/board.rs` (subagent): column accents, label pills, focus ring, empty states, appearance settings, search-filtered rendering
- [x] `src/ui/chrome.rs` (subagent): topbar (brand, connection dot, project@user, search box, buttons), context-aware footer

## Phase 5 — Modal redesign
- [x] `src/ui/modals.rs` (subagent): issue editor + notes + palette on shared modal chrome
- [x] `src/ui/confirm.rs` (subagent): confirm dialog (destructive actions)
- [x] `src/ui/settings_ui.rs` (subagent): tabbed settings modal, masked secrets, inline validation, save feedback

## Phase 6 — Markdown + Generate Stories
- [x] `src/ui/markdown.rs` (subagent): themed renderer, links underlined, italic, ordered lists, tests
- [x] `src/ui/stories.rs` (subagent): Edit/Preview/Generated tabs, scrolling source, spinner while generating, review/select/edit/create flow

## Phase 7 — Help, keys, tests, lint
- [x] `src/ui/help.rs` (subagent): `?` overlay with grouped shortcuts
- [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
- [x] Port/update render tests to new theme; new tests: settings, search nav, confirm flow, story tabs, help
- [x] cargo fmt + cargo test + clippy clean
- [x] Commit, push, draft PR

## Acceptance criteria mapping
- Env vars optional → Phase 2
- Settings persist + guide → Phase 2
- Async feedback everywhere → Phase 3
- Markdown/story flow → Phase 6
- Navigation/modals/theme → Phases 1/4/5/7
- Backend intact → no changes beyond status kinds
64 changes: 46 additions & 18 deletions src/jobs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -822,7 +822,7 @@
description
}

pub fn apply_job_result(app: &mut crate::App, result: JobResult) {

Check failure on line 825 in src/jobs.rs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 45 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=RandomCodeSpace_lane&issues=AZ8j3TpnEFDhbkHNgyf-&open=AZ8j3TpnEFDhbkHNgyf-&pullRequest=7
match result {
JobResult::GeneratedStories {
generation_id,
Expand All @@ -832,7 +832,10 @@
app.ai_job_in_flight = false;
let count = stories.len();
app.set_generated_stories(stories);
app.story_status = Some(format!("Generated {count} stories"));
app.set_status(
crate::StatusKind::Success,
format!("Generated {count} stories"),
);
}
}
JobResult::Connected(Ok(state)) => {
Expand All @@ -842,6 +845,7 @@
app.open_assigned_set.clear();
app.has_synced = false;
app.replace_issue_records(&state.issues);
app.status_kind = crate::StatusKind::Success;
app.story_status = Some(format!(
"Connected as {} on {}",
app.connected_user
Expand All @@ -855,6 +859,7 @@
));
}
JobResult::BootstrappedLabels(Ok(summary)) => {
app.status_kind = crate::StatusKind::Success;
app.story_status = Some(format!(
"Bootstrap: {} created, {} already present",
summary.created.len(),
Expand All @@ -870,27 +875,30 @@
app.close_overlay();
}
}
app.story_status = Some(format!("Created issue #{iid}"));
app.set_status(crate::StatusKind::Success, format!("Created issue #{iid}"));
}
JobResult::IssueUpdated(Ok(issue)) => {
let iid = issue.iid;
app.upsert_issue_record(&issue);
if app.editor_submit_in_flight {
app.editor_submit_in_flight = false;
if app.overlay == Some(crate::Overlay::Details) {
if app.overlay == Some(crate::Overlay::EditIssue) {
app.close_overlay();
}
}
app.story_status = Some(format!("Saved issue #{iid}"));
app.set_status(crate::StatusKind::Success, format!("Saved issue #{iid}"));
}
JobResult::IssueDeleted(Ok(iid)) => {
app.remove_issue(iid);
app.story_status = Some(format!("Deleted issue #{iid}"));
app.set_status(crate::StatusKind::Success, format!("Deleted issue #{iid}"));
}
JobResult::NotesLoaded(Ok((iid, request_id, notes))) => {
let count = notes.len();
if app.set_notes_for_issue(iid, request_id, notes) {
app.story_status = Some(format!("Loaded {count} notes for #{iid}"));
app.set_status(
crate::StatusKind::Success,
format!("Loaded {count} notes for #{iid}"),
);
}
}
JobResult::NoteCreated(Ok((iid, request_id, body, note))) => {
Expand All @@ -907,7 +915,10 @@
if matches_pending {
app.note_submit_pending = None;
}
app.story_status = Some(format!("Created note on #{iid}"));
app.set_status(
crate::StatusKind::Success,
format!("Created note on #{iid}"),
);
}
}
JobResult::NoteCreated(Err(err)) => {
Expand All @@ -919,7 +930,7 @@
});
if matches_pending {
app.note_submit_pending = None;
app.story_status = Some(err.error);
app.set_status(crate::StatusKind::Error, err.error);
}
}
JobResult::SourceSnapshotPulled(Ok(iid)) => {
Expand All @@ -932,15 +943,21 @@
request_id: app.notes_session_id,
});
}
app.story_status = Some(format!("Pulled source snapshot into #{iid}"));
app.set_status(
crate::StatusKind::Success,
format!("Pulled source snapshot into #{iid}"),
);
}
JobResult::GeneratedStories {
generation_id,
result: Err(err),
} => {
if app.ai_generation_id == generation_id {
app.ai_job_in_flight = false;
app.story_status = Some(format!("Story generation failed: {err}"));
app.set_status(
crate::StatusKind::Error,
format!("Story generation failed: {err}"),
);
}
}
JobResult::ImprovedDescription {
Expand Down Expand Up @@ -969,6 +986,8 @@
if app.ai_generation_id == generation_id {
app.ai_job_in_flight = false;
app.editor_labels = labels;
app.editor_labels_input = app.editor_labels.join(", ");
app.status_kind = crate::StatusKind::Success;
app.story_status = Some(format!(
"Suggested labels: {}",
app.editor_labels.join(", ")
Expand All @@ -981,15 +1000,24 @@
let skipped = sync.skipped.len();
app.open_assigned_set = sync.open_assigned_set;
app.has_synced = true;
app.last_synced = Some(std::time::Instant::now());
app.replace_issue_records(&sync.issues);
app.story_status = Some(format!("Forked {forked}, skipped {skipped}, total {total}"));
app.set_status(
crate::StatusKind::Success,
format!("Forked {forked}, skipped {skipped}, total {total}"),
);
}
JobResult::CreatedStories(result) => {
app.story_create_in_flight = false;
let created = result.created;
let failed = result.failed;
app.append_issue_records(&created);
app.deselect_created_story_drafts(&created, &failed);
app.status_kind = if failed.is_empty() {
crate::StatusKind::Success
} else {
crate::StatusKind::Error
};
app.story_status = Some(match (created.len(), failed.len()) {
(created_count, 0) => format!("Created {created_count} stories"),
(0, 1) => failed[0].error.clone(),
Expand All @@ -1001,15 +1029,15 @@
}
JobResult::IssueCreated(Err(err)) | JobResult::IssueUpdated(Err(err)) => {
app.editor_submit_in_flight = false;
app.story_status = Some(err);
app.set_status(crate::StatusKind::Error, err);
}
JobResult::Synced(Err(err))
| JobResult::IssueDeleted(Err(err))
| JobResult::Connected(Err(err))
| JobResult::BootstrappedLabels(Err(err))
| JobResult::NotesLoaded(Err(err))
| JobResult::SourceSnapshotPulled(Err(err)) => {
app.story_status = Some(err);
app.set_status(crate::StatusKind::Error, err);
}
JobResult::ImprovedDescription {
generation_id,
Expand All @@ -1025,7 +1053,7 @@
} => {
if app.ai_generation_id == generation_id {
app.ai_job_in_flight = false;
app.story_status = Some(err);
app.set_status(crate::StatusKind::Error, err);
}
}
}
Expand Down Expand Up @@ -2551,7 +2579,7 @@
#[test]
fn issue_updated_success_closes_pending_details_modal() {
let mut app = crate::App::demo();
app.open_details();
app.open_edit_issue();
app.editor_submit_in_flight = true;

apply_job_result(
Expand All @@ -2570,20 +2598,20 @@
JobResult::IssueUpdated(Err("update failed".into())),
] {
let mut app = crate::App::demo();
app.open_details();
app.open_edit_issue();
app.editor_submit_in_flight = true;

apply_job_result(&mut app, result);

assert_eq!(app.overlay, Some(crate::Overlay::Details));
assert_eq!(app.overlay, Some(crate::Overlay::EditIssue));
assert!(!app.editor_submit_in_flight);
}
}

#[test]
fn ai_editor_stale_result_does_not_overwrite_after_cancel() {
let mut app = crate::App::demo();
app.open_details();
app.open_edit_issue();
app.editor_description = "keep me".into();
app.request_improve_description();
let generation_id = app.ai_generation_id;
Expand Down
Loading