Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73e9ecfc85 | ||
|
|
3e809db1bc | ||
|
|
e8bd8a4952 | ||
|
|
9c6e06ec4f | ||
|
|
3029de667e |
@@ -261,17 +261,13 @@ impl ProjectScanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Cached snapshot, rescanning (off-thread) when the TTL has lapsed.
|
/// Cached snapshot, rescanning (off-thread) when the TTL has lapsed.
|
||||||
|
/// Also the background timer's entry point: a tick inside the TTL is a
|
||||||
|
/// no-op (#75).
|
||||||
pub async fn repos(&self) -> std::sync::Arc<Vec<DiscoveredRepo>> {
|
pub async fn repos(&self) -> std::sync::Arc<Vec<DiscoveredRepo>> {
|
||||||
self.repos_inner(false).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn repos_inner(&self, force: bool) -> std::sync::Arc<Vec<DiscoveredRepo>> {
|
|
||||||
let mut cache = self.cache.lock().await;
|
let mut cache = self.cache.lock().await;
|
||||||
if !force {
|
if let Some((at, repos)) = cache.as_ref() {
|
||||||
if let Some((at, repos)) = cache.as_ref() {
|
if at.elapsed() < self.ttl {
|
||||||
if at.elapsed() < self.ttl {
|
return repos.clone();
|
||||||
return repos.clone();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let roots = self.roots.clone();
|
let roots = self.roots.clone();
|
||||||
@@ -287,11 +283,11 @@ impl ProjectScanner {
|
|||||||
repos
|
repos
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rescan now, ignoring the TTL. Called by the background timer — going
|
/// One background-refresh tick: rescan only if the cached snapshot has
|
||||||
/// through `repos()` would hit the cache and make most ticks no-ops,
|
/// passed its TTL (#75) — the timer used to bypass it and re-walk the
|
||||||
/// leaving requests to pay for the rescan instead (#54 review).
|
/// roots every [`REFRESH_INTERVAL`].
|
||||||
async fn refresh(&self) {
|
async fn refresh(&self) {
|
||||||
self.repos_inner(true).await;
|
self.repos().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn the slow background refresh task. Drop the returned handle to
|
/// Spawn the slow background refresh task. Drop the returned handle to
|
||||||
@@ -544,6 +540,32 @@ mod tests {
|
|||||||
assert_eq!(fresh[0].name, "late");
|
assert_eq!(fresh[0].name, "late");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn background_refresh_honours_the_ttl() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let mut scanner = ProjectScanner::new(
|
||||||
|
vec![tmp.path().to_path_buf()],
|
||||||
|
3,
|
||||||
|
PathBuf::from("/nonexistent"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let empty = scanner.repos().await;
|
||||||
|
assert!(empty.is_empty());
|
||||||
|
|
||||||
|
make_repo(tmp.path(), "late");
|
||||||
|
// A background tick inside the TTL must not re-walk the filesystem:
|
||||||
|
// the new repo stays invisible until the cache expires.
|
||||||
|
scanner.refresh().await;
|
||||||
|
assert!(scanner.repos().await.is_empty());
|
||||||
|
|
||||||
|
// Once the TTL lapses, the next tick picks it up.
|
||||||
|
scanner.ttl = Duration::ZERO;
|
||||||
|
scanner.refresh().await;
|
||||||
|
let fresh = scanner.repos().await;
|
||||||
|
assert_eq!(fresh.len(), 1);
|
||||||
|
assert_eq!(fresh[0].name, "late");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn disabled_scanner_always_serves_empty() {
|
fn disabled_scanner_always_serves_empty() {
|
||||||
let scanner = ProjectScanner::disabled();
|
let scanner = ProjectScanner::disabled();
|
||||||
|
|||||||
@@ -238,26 +238,6 @@ mod tests {
|
|||||||
assert!(s.contains("ctrl-glorb"), "diagnostic: {s}");
|
assert!(s.contains("ctrl-glorb"), "diagnostic: {s}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Regression for #70: the previous canonical form of a real Shift+S
|
|
||||||
/// keypress was `"shift-S"`, which the default `"S"` binding never
|
|
||||||
/// matched. Drive the actual crossterm event, not the binding string,
|
|
||||||
/// so the dead-key bug can't come back unnoticed.
|
|
||||||
#[test]
|
|
||||||
fn real_shift_s_keypress_resolves_to_fast_forward_spawn() {
|
|
||||||
use crossterm::event::{Event as CtEvent, KeyCode, KeyEvent, KeyModifiers};
|
|
||||||
|
|
||||||
use crate::input::{canonicalise, Region};
|
|
||||||
|
|
||||||
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
|
||||||
let ev = CtEvent::Key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT));
|
|
||||||
let key = canonicalise(&ev, |_, _| Region::Unknown).expect("key event canonicalises");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
cfg.keymap.resolve(Mode::Normal, &key.canonical()),
|
|
||||||
Some(Action::FastForwardSpawn),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn custom_chord_timeout_is_applied() {
|
fn custom_chord_timeout_is_applied() {
|
||||||
let mut section = TuiSection::default();
|
let mut section = TuiSection::default();
|
||||||
|
|||||||
@@ -117,9 +117,7 @@ impl InputKey {
|
|||||||
if *alt {
|
if *alt {
|
||||||
out.push_str("alt-");
|
out.push_str("alt-");
|
||||||
}
|
}
|
||||||
// Uppercase already implies shift; crossterm sets the flag
|
if *shift {
|
||||||
// on every such char, so skip the redundant prefix (#70).
|
|
||||||
if *shift && !c.is_uppercase() {
|
|
||||||
out.push_str("shift-");
|
out.push_str("shift-");
|
||||||
}
|
}
|
||||||
out.push(*c);
|
out.push(*c);
|
||||||
@@ -582,28 +580,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn canonical_round_trips_simple_chars() {
|
fn canonical_round_trips_simple_chars() {
|
||||||
for s in ["a", "ctrl-a", "ctrl-w", "alt-x"] {
|
for s in ["a", "ctrl-a", "ctrl-w", "alt-x", "shift-A"] {
|
||||||
let k = InputKey::parse(s).unwrap();
|
let k = InputKey::parse(s).unwrap();
|
||||||
assert_eq!(k.canonical(), s, "round-trip {s:?}");
|
assert_eq!(k.canonical(), s, "round-trip {s:?}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// crossterm 0.28 attaches `KeyModifiers::SHIFT` to any uppercase char,
|
|
||||||
/// so a real Shift+S delivers `Char { c: 'S', shift: true }`. Its
|
|
||||||
/// canonical form must be the bare `"S"` a keymap binds, not `"shift-S"` —
|
|
||||||
/// the uppercase letter already says "shifted"; a redundant prefix would
|
|
||||||
/// make the default `S` binding unreachable from the keyboard (#70).
|
|
||||||
#[test]
|
|
||||||
fn shift_flag_is_redundant_for_an_already_uppercase_char() {
|
|
||||||
let k = InputKey::Char {
|
|
||||||
c: 'S',
|
|
||||||
ctrl: false,
|
|
||||||
alt: false,
|
|
||||||
shift: true,
|
|
||||||
};
|
|
||||||
assert_eq!(k.canonical(), "S");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn canonical_round_trips_specials() {
|
fn canonical_round_trips_specials() {
|
||||||
for s in [
|
for s in [
|
||||||
|
|||||||
@@ -147,6 +147,48 @@ describe("multiview openDraft (#62)", () => {
|
|||||||
expect((mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement).value).toBe("wip");
|
expect((mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement).value).toBe("wip");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reuses an already-synced empty pane instead of splitting (#72)", async () => {
|
||||||
|
const layout = emptyLayout();
|
||||||
|
const tab = layout.tabs[0];
|
||||||
|
const first = splitLeaf(tab, "", "s1", "row", true);
|
||||||
|
const empty = splitLeaf(tab, first, null, "row", true);
|
||||||
|
tab.focusedLeafId = empty;
|
||||||
|
const mv = makeMv(layout);
|
||||||
|
document.body.appendChild(mv.el);
|
||||||
|
mv.render();
|
||||||
|
// Let the mounted form's loadProjects() settle and auto-sync its picked
|
||||||
|
// defaults into leaf.draft before openDraft runs (#72's dead-branch window).
|
||||||
|
await flush();
|
||||||
|
expect(leaves(tab.root).find((l) => l.id === empty)?.draft).not.toBeNull();
|
||||||
|
|
||||||
|
mv.openDraft();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(leaves(tab.root)).toHaveLength(2);
|
||||||
|
expect(tab.focusedLeafId).toBe(empty);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a seed after reusing an already-synced empty pane (#72)", async () => {
|
||||||
|
const layout = emptyLayout();
|
||||||
|
const tab = layout.tabs[0];
|
||||||
|
const first = splitLeaf(tab, "", "s1", "row", true);
|
||||||
|
const empty = splitLeaf(tab, first, null, "row", true);
|
||||||
|
tab.focusedLeafId = empty;
|
||||||
|
const mv = makeMv(layout);
|
||||||
|
document.body.appendChild(mv.el);
|
||||||
|
mv.render();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
mv.openDraft({ cwd: "/home/user/proj", command: "claude" });
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const leaf = leaves(tab.root).find((l) => l.id === empty)!;
|
||||||
|
expect(leaf.draft?.cwd).toBe("/home/user/proj");
|
||||||
|
expect(leaf.draft?.command).toBe("claude");
|
||||||
|
const cwdRaw = mv.el.querySelector(".mv-draft .cwd-raw") as HTMLInputElement;
|
||||||
|
expect(cwdRaw.value).toBe("/home/user/proj");
|
||||||
|
});
|
||||||
|
|
||||||
it("seeds a clone's cwd and command into the draft", async () => {
|
it("seeds a clone's cwd and command into the draft", async () => {
|
||||||
const layout = emptyLayout();
|
const layout = emptyLayout();
|
||||||
const mv = makeMv(layout);
|
const mv = makeMv(layout);
|
||||||
|
|||||||
@@ -423,9 +423,15 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const target = findLeaf(tab.root, tab.focusedLeafId)!;
|
const target = findLeaf(tab.root, tab.focusedLeafId)!;
|
||||||
if (!target.sessionId && !target.draft) {
|
if (!target.sessionId) {
|
||||||
// An already-empty pane becomes the draft in place.
|
// Empty = no session, not `!draft` (#72): the mounted form auto-syncs
|
||||||
|
// picked defaults into `draft` once ready, so that's never reliably null.
|
||||||
target.draft = draft();
|
target.draft = draft();
|
||||||
|
const stale = draftForms.get(target.id);
|
||||||
|
if (stale) {
|
||||||
|
stale.destroy();
|
||||||
|
draftForms.delete(target.id);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Split a fresh draft beside the focused pane.
|
// Split a fresh draft beside the focused pane.
|
||||||
tab.focusedLeafId = splitLeaf(tab, target.id, null, "row", true);
|
tab.focusedLeafId = splitLeaf(tab, target.id, null, "row", true);
|
||||||
|
|||||||
@@ -1215,6 +1215,9 @@ textarea {
|
|||||||
/* ---------- forms ---------- */
|
/* ---------- forms ---------- */
|
||||||
|
|
||||||
.spawn-form {
|
.spawn-form {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 60ch;
|
||||||
|
margin: 0 auto;
|
||||||
padding: var(--sp-3);
|
padding: var(--sp-3);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user