diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 5b4f1c8d..364c7e0d 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -23,6 +23,7 @@ tauri-build = { workspace = true } winreg = "0.55" windows = { version = "0.62", features = [ "ApplicationModel", + "Management_Deployment", "Services_Store", "Win32_Foundation", "Win32_System_Com", diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index a3ac9dd6..bfd047a4 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -2551,7 +2551,7 @@ async fn update_ready( if !asked && !update::due(last, now) { return Ok(last_said()); } - let shelf = tauri::async_runtime::spawn_blocking(move || shop::asked(window)) + let shelf = tauri::async_runtime::spawn_blocking(move || shop::asked(window, asked)) .await .unwrap_or(shop::Shelf::Silent); return match shelf { @@ -2564,6 +2564,20 @@ async fn update_ready( })?; Ok(seen) } + // Already down and waiting for this window to close: there is nothing to fetch, so + // it is announced without the button that would ask the Store for it again. + shop::Shelf::Landed(version) => { + let seen = update::from_the_shop(&version, HERE).map(|one| update::Ready { + installs: false, + ..one + }); + held(&session).keep(|c| { + c.checked_at = Some(now); + c.found_version = seen.as_ref().map(|one| one.version.clone()); + c.found_in_the_shop = seen.as_ref().map(|_| true); + })?; + Ok(seen) + } shop::Shelf::Current => { held(&session).keep(|c| { c.checked_at = Some(now); @@ -3088,6 +3102,19 @@ struct Filed { #[serde(skip_serializing_if = "Option::is_none")] guest: Option, page_of: Option, + #[serde(skip_serializing_if = "Option::is_none")] + flagged: Option, + #[serde(skip_serializing_if = "Option::is_none")] + folder_was: Option>, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct Marked { + at: String, + said: String, + #[serde(skip_serializing_if = "Option::is_none")] + via: Option, } const CATCHING_UP_AT_ONCE: usize = 500; @@ -3188,6 +3215,12 @@ fn gathered(session: &Session) -> Vec { guest: kept.guest.then(|| kept.by.clone().unwrap_or_default()), page_of: kept.page_of.map(|up| up.to_string()), tags: kept.tags.iter().map(|one| one.to_string()).collect(), + flagged: kept.flagged.as_ref().map(|mark| Marked { + at: mark.at.to_string(), + said: mark.body.clone(), + via: mark.via.clone(), + }), + folder_was: kept.folder_was.clone(), }) .collect() } @@ -3265,7 +3298,7 @@ fn folder_add( parent: Option, icon: Option, color: Option, -) -> Answer<()> { +) -> Answer { let name = named_folder(&name)?; let parent = parent .map(|at| at.parse().map_err(|_| Refusal::of("noSuchFolder"))) @@ -3296,8 +3329,9 @@ fn folder_add( .iter() .map(|one| one.order.as_str()), ); + let id = ulid::Ulid::generate(); session.commit(Op::FolderAdd { - id: ulid::Ulid::generate(), + id, d: tisty_core::event::FolderAdd { name, order, @@ -3306,7 +3340,7 @@ fn folder_add( color: painted, }, })?; - Ok(()) + Ok(id.to_string()) } #[tauri::command] @@ -4283,6 +4317,19 @@ fn doc_away(session: tauri::State<'_, Mutex>, id: String, away: bool) - Ok(()) } +#[tauri::command] +fn doc_unflag(session: tauri::State<'_, Mutex>, id: String) -> Answer<()> { + let id = id.parse().map_err(|_| Refusal::of("noSuchDoc"))?; + let mut session = held(&session); + match session.state.docs.get(&id) { + None => return Err(Refusal::of("noSuchDoc")), + Some(one) if one.flagged.is_none() => return Ok(()), + Some(_) => {} + } + session.commit(Op::DocUnflag { id })?; + Ok(()) +} + #[tauri::command] fn folder_away(session: tauri::State<'_, Mutex>, id: String, away: bool) -> Answer<()> { let id = id.parse().map_err(|_| Refusal::of("noSuchFolder"))?; @@ -7354,6 +7401,7 @@ pub fn run() { doc_let_go, retire_attachments, doc_away, + doc_unflag, folder_away, doc_lock, parted, diff --git a/app/src-tauri/src/shop.rs b/app/src-tauri/src/shop.rs index 0e503b9c..25123de1 100644 --- a/app/src-tauri/src/shop.rs +++ b/app/src-tauri/src/shop.rs @@ -5,6 +5,9 @@ #[derive(Debug, Clone, PartialEq, Eq)] pub enum Shelf { Waiting(String), + /// Already on this machine, staged beside the copy in use, and it takes over when this one + /// closes. Nothing left to download, so nothing to offer but the closing. + Landed(String), Current, Silent, } @@ -27,8 +30,8 @@ pub fn step(far: f64) -> (&'static str, u64) { ("getting", (far.max(0.0) / DOWNLOADED * 100.0) as u64) } -pub fn asked(window: isize) -> Shelf { - there::asked(window) +pub fn asked(window: isize, forced: bool) -> Shelf { + there::asked(window, forced) } pub fn take( @@ -53,6 +56,11 @@ mod there { use windows_future::AsyncOperationProgressHandler; const PATIENCE: std::time::Duration = std::time::Duration::from_secs(10); + /// Asking wakes the Store's own errand, and its answer is the state from before it looked: + /// at 15:06 it says nothing and at 15:07 it is installing. A person who pressed the button + /// is owed the answer that comes out of that errand, not the one that preceded it. + const AGAIN: std::time::Duration = std::time::Duration::from_secs(6); + const UNTIL: std::time::Duration = std::time::Duration::from_secs(75); /// Long past the point where the process should already have been taken down by the install. const AT_LENGTH: std::time::Duration = std::time::Duration::from_secs(30 * 60); @@ -78,7 +86,7 @@ mod there { .is_ok_and(|kind| kind == PackageSignatureKind::Store) } - pub fn asked(window: isize) -> Shelf { + pub fn asked(window: isize, forced: bool) -> Shelf { if !sold_here() { witness::warn( channel::WINDOW, @@ -87,8 +95,12 @@ mod there { ); return Shelf::Silent; } + if let Some(version) = beside_us() { + return Shelf::Landed(version); + } match apart(PATIENCE, move || waiting(window)) { Some(Ok(Some(version))) => Shelf::Waiting(version), + Some(Ok(None)) if forced => waited_out(window), Some(Ok(None)) => Shelf::Current, Some(Err(why)) => { witness::warn( @@ -109,6 +121,58 @@ mod there { } } + /// The Store was just asked and said nothing, which is what it says while its own errand is + /// still running. Waiting it out turns «nothing for you» into what it actually found. + fn waited_out(window: isize) -> Shelf { + let since = std::time::Instant::now(); + while since.elapsed() < UNTIL { + std::thread::sleep(AGAIN); + if let Some(version) = beside_us() { + witness::note( + channel::WINDOW, + "the Store brought the update down while the person waited on the answer", + &[("version", Fact::Id(version.clone()))], + ); + return Shelf::Landed(version); + } + if let Some(Ok(Some(version))) = apart(PATIENCE, move || waiting(window)) { + witness::note( + channel::WINDOW, + "the Store named an update only after its own errand had run", + &[ + ("version", Fact::Id(version.clone())), + ("waited", Fact::Count(since.elapsed().as_secs() as usize)), + ], + ); + return Shelf::Waiting(version); + } + } + Shelf::Current + } + + /// A newer package of our own family already registered for this user is one the Store has + /// finished bringing down: it cannot replace a copy that is running, so it waits for the + /// window to close. The Store's own answer never mentions it — there is nothing left to get. + fn beside_us() -> Option { + let ours = Package::Current().ok()?; + let family = ours.Id().ok()?.FamilyName().ok()?; + let here = numbered(&ours.Id().ok()?.Version().ok()?); + let shelf = windows::Management::Deployment::PackageManager::new().ok()?; + let mut newest: Option = None; + let mine: semver::Version = here.parse().ok()?; + for one in shelf.FindPackagesByPackageFamilyName(&family).ok()? { + let Ok(id) = one.Id() else { continue }; + let Ok(version) = id.Version() else { continue }; + let Ok(said) = numbered(&version).parse::() else { + continue; + }; + if said > mine && newest.as_ref().is_none_or(|had| said > *had) { + newest = Some(said); + } + } + newest.map(|one| one.to_string()) + } + /// The errand covers optional packages too, and the version of one of those is not a version /// of Tisty to put in front of anybody. fn waiting(window: isize) -> windows::core::Result> { @@ -234,7 +298,7 @@ mod there { mod there { use super::{Shelf, Trouble}; - pub fn asked(_window: isize) -> Shelf { + pub fn asked(_window: isize, _forced: bool) -> Shelf { Shelf::Silent } diff --git a/app/src/App.tsx b/app/src/App.tsx index be053738..a13329f7 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -236,6 +236,8 @@ export default function App() { const [whoFor, setWhoFor] = useState(null); const [movingTo, setMovingTo] = useState(null); const [locked, setLocked] = useState(null); + const [backing, setBacking] = useState(null); + const [backTo, setBackTo] = useState("same"); const [number, setNumber] = useState(""); const [wrong, setWrong] = useState(false); const [menu, setMenu] = useState<{ @@ -477,6 +479,62 @@ export default function App() { }) .catch((e) => setError(saidPlainly(e))); + const bringBack = (doc: Filed) => { + setBackTo("same"); + setBacking(doc); + }; + + const backHome = (doc: Filed): string | null => { + if (doc.folder && papers.folders.some((one) => one.id === doc.folder)) return doc.folder; + const was = (doc.folderWas ?? []).join(" / "); + const again = was + ? papers.folders.find((one) => !one.away && trail(one.id) === was) + : undefined; + return again?.id ?? null; + }; + + const backFrom = (doc: Filed): string | null => { + const home = backHome(doc); + if (home) return trail(home); + const was = doc.folderWas ?? []; + return was.length ? was.join(" / ") : null; + }; + + const madeAgain = async (way: string[]): Promise => { + let parent: string | null = null; + for (const name of way) { + const here = papers.folders.find( + (one) => !one.away && one.name === name && (one.parent ?? null) === parent, + ); + parent = here ? here.id : await folderAdd(name, parent ?? undefined); + } + return parent; + }; + + const putBack = () => { + const doc = backing; + if (!doc) return; + setBacking(null); + const home = backHome(doc); + const lands = (): Promise => + backTo === "none" + ? Promise.resolve(null) + : backTo !== "same" + ? Promise.resolve(backTo) + : home + ? Promise.resolve(home) + : doc.folderWas?.length + ? madeAgain(doc.folderWas) + : Promise.resolve(null); + docAway(doc.id, false) + .then(lands) + .then((folder) => + folder === (doc.folder ?? null) ? undefined : docFile(doc.id, folder ?? undefined), + ) + .then(papersChanged) + .catch((e) => setError(saidPlainly(e))); + }; + const deep = (at: string | null | undefined): number => { let steps = 0; const seen = new Set(); @@ -1200,10 +1258,12 @@ export default function App() { label: doc.archived ? t("bringBack") : t("putAway"), off: !!doc.pageOf || byFolder(doc), apart: true, - onPick: () => - docAway(doc.id, !doc.archived) + onPick: () => { + if (doc.archived) return bringBack(doc); + docAway(doc.id, true) .then(papersChanged) - .catch((e) => setError(saidPlainly(e))), + .catch((e) => setError(saidPlainly(e))); + }, }, { key: "drop", @@ -1264,6 +1324,105 @@ export default function App() {
+ {backing !== null && ( + setBacking(null)} + > +

+ {backFrom(backing) === null + ? t("backFromNowhere") + : fill(backHome(backing) ? "backFrom" : "backFromGone", backFrom(backing) as string)} +

+
{ + e.preventDefault(); + putBack(); + }} + > +
+ {t("backWhere").replace("{name}", "")} + + {backFrom(backing) !== null && ( + + )} + {papers.folders.some((one) => !one.away && one.id !== backHome(backing)) && ( + + )} +
+
+ + +
+
+
+ )} + {whoFor !== null && ( setWhoFor(null)}>

{t("packWhoWhy")}

@@ -1706,6 +1865,8 @@ export default function App() { .then(papersChanged) .catch((e) => setError(saidPlainly(e))) } + onDrop={dropDoc} + onBack={bringBack} fresh={carried} /> ) : chosen.named === "lists" && !chosen.list ? ( @@ -2151,6 +2312,37 @@ export default function App() { ); } +function Where({ + name, + value, + chosen, + label, + hint, + onPick, +}: { + name: string; + value: string; + chosen: string; + label: string; + hint?: string; + onPick: (value: string) => void; +}) { + return ( + + ); +} + const sliceWord = (slice: Slice) => slice === "today" ? ("today" as const) diff --git a/app/src/core.ts b/app/src/core.ts index 47af99d6..a9089fb3 100644 --- a/app/src/core.ts +++ b/app/src/core.ts @@ -766,6 +766,12 @@ export interface Folded { export type Paper = "a4" | "letter" | "tabloid"; +export interface Marked { + at: string; + said: string; + via?: string | null; +} + export interface Filed { id: string; file: string; @@ -783,6 +789,8 @@ export interface Filed { gone?: boolean; guest?: string | null; tags?: string[]; + flagged?: Marked | null; + folderWas?: string[] | null; } export interface Papers { @@ -822,7 +830,7 @@ export const folderAdd = ( parent?: string, icon?: string, color?: string, -): Promise => invoke("folder_add", { name, parent, icon, color }); +): Promise => invoke("folder_add", { name, parent, icon, color }); export const folderRename = (id: string, name: string): Promise => invoke("folder_rename", { id, name }); export const folderLook = (id: string, icon?: string, color?: string): Promise => @@ -848,6 +856,7 @@ export const folderAway = (id: string, away: boolean): Promise => invoke("folder_away", { id, away }); export const docLock = (id: string, shut: boolean): Promise => invoke("doc_lock", { id, shut }); +export const docUnflag = (id: string): Promise => invoke("doc_unflag", { id }); export const docCopy = (id: string): Promise => invoke("doc_copy", { id }); diff --git a/app/src/index.css b/app/src/index.css index 6001f1e7..b3131788 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -46,6 +46,7 @@ --tisty-mark-doc: rgb(88 88 100 / 0.12); --tisty-mark-doc-lit: rgb(88 88 100 / 0.2); --tisty-veil: rgb(0 0 0 / 0.28); + --tisty-docs-head: 30px; --tisty-lift: 0 4px 14px rgb(0 0 0 / 0.1), 0 1px 3px rgb(0 0 0 / 0.06); --tisty-lift-tall: 0 16px 44px rgb(0 0 0 / 0.16), 0 2px 8px rgb(0 0 0 / 0.08); } @@ -233,6 +234,14 @@ body { .scroller:has(.carrying) { overscroll-behavior-y: contain; } + +.scroller:has(.carrying) .pinned-head { + pointer-events: none; +} + +.scroller:has(.pinned-head) [data-row] { + scroll-margin-top: calc(var(--tisty-docs-head) + 22px); +} .scroller:hover { scrollbar-color: var(--tisty-line) transparent; } diff --git a/app/src/locales.ts b/app/src/locales.ts index 6925d415..487e56f1 100644 --- a/app/src/locales.ts +++ b/app/src/locales.ts @@ -549,6 +549,23 @@ const en = { unlockIt: "Unlock it", bringBack: "Unarchive", deleteIt: "Delete", + docFlagged: "An agent says this has had its day", + docFlaggedBy: "{name} says this has had its day", + unflagIt: "Take the mark off", + backWhere: "Unarchive «{name}»", + backFrom: "It came out of {name}.", + backFromGone: "It came out of {name}. That folder is no longer here.", + backFromNowhere: "It was in no folder.", + backToSame: "Back to {name}", + backToMade: "Make {name} again", + backToNone: "No folder", + backToOther: "Another folder", + backWasHere: "where it was", + backAtRoot: "outside every folder", + folderTrace: "path", + folderTraceWhy: + "The folder is still there, outside the archive. This only says where the document came from.", + folderTraceGone: "This folder was deleted while the document waited here.", folderActions: "Folder options", docActions: "Document options", docsActions: "Document section options", @@ -1084,6 +1101,7 @@ const en = { lookNow: "Check for updates", lookingNow: "Checking…", lookNowNone: "You are on the newest version", + updateLanded: "It is already on this machine and takes over when you close Tisty", lookNowNoneStore: "The Microsoft Store has nothing newer for this copy right now. It is asked again at every launch and every few hours, and brings the update here when it has it", agentsTitle: "Tisty has no AI inside it", @@ -1855,6 +1873,23 @@ const es: Catalog = { unlockIt: "Desbloquear", bringBack: "Desarchivar", deleteIt: "Eliminar", + docFlagged: "Un agente lo da por viejo", + docFlaggedBy: "{name} lo da por viejo", + unflagIt: "Quitar la marca", + backWhere: "Desarchivar «{name}»", + backFrom: "Salió de {name}.", + backFromGone: "Salió de {name}. Esa carpeta ya no está.", + backFromNowhere: "No estaba en ninguna carpeta.", + backToSame: "Volver a {name}", + backToMade: "Crear {name} otra vez", + backToNone: "Sin carpeta", + backToOther: "Otra carpeta", + backWasHere: "donde estaba", + backAtRoot: "fuera de toda carpeta", + folderTrace: "camino", + folderTraceWhy: + "La carpeta sigue ahí, fuera del archivo. Esto solo dice de dónde salió el documento.", + folderTraceGone: "Esta carpeta se borró mientras el documento esperaba aquí.", folderActions: "Opciones de la carpeta", docActions: "Opciones del documento", docsActions: "Opciones de documentos", @@ -2391,6 +2426,7 @@ const es: Catalog = { lookNow: "Buscar actualizaciones", lookingNow: "Buscando…", lookNowNone: "Estás en la versión más reciente", + updateLanded: "Ya está en esta máquina y entra cuando cierres Tisty", lookNowNoneStore: "La Microsoft Store no tiene nada más nuevo para esta copia ahora mismo. Se le vuelve a preguntar en cada arranque y cada pocas horas, y trae la actualización aquí cuando la tiene", agentsTitle: "Tisty no lleva IA dentro", diff --git a/app/src/tests/lifecycle.test.tsx b/app/src/tests/lifecycle.test.tsx index bdb7cc18..af1722e9 100644 --- a/app/src/tests/lifecycle.test.tsx +++ b/app/src/tests/lifecycle.test.tsx @@ -23,6 +23,8 @@ interface FakeDoc { folder: string | null; archived: boolean; pageOf?: string; + flagged?: { at: string; said: string; via?: string }; + folderWas?: string[]; } const store = vi.hoisted(() => ({ @@ -220,7 +222,15 @@ function backend(cmd: string, args: Record): Promise { } case "doc_away": { const doc = store.docs.find((one) => one.id === args.id); - if (doc) doc.archived = Boolean(args.away); + if (doc) { + doc.archived = Boolean(args.away); + if (doc.archived) doc.flagged = undefined; + } + return Promise.resolve(null); + } + case "doc_unflag": { + const doc = store.docs.find((one) => one.id === args.id); + if (doc) doc.flagged = undefined; return Promise.resolve(null); } case "folder_away": { @@ -311,7 +321,7 @@ function backend(cmd: string, args: Record): Promise { icon: (args.icon as string | undefined) ?? null, color: (args.color as string | undefined) ?? null, }); - return Promise.resolve(null); + return Promise.resolve(id); } default: return Promise.resolve(null); @@ -378,17 +388,22 @@ async function boot() { unfoldAll(); } -function menuFor(rowLabel: string): HTMLElement { +function menuFor(rowLabel: string | RegExp): HTMLElement { return screen.getByRole("button", { name: rowLabel }).parentElement as HTMLElement; } const SETTLES_LONG_ENOUGH = 900; -async function chooseFor(rowLabel: string, itemLabel: string) { +async function chooseFor(rowLabel: string | RegExp, itemLabel: string) { fireEvent.contextMenu(menuFor(rowLabel), { clientX: 5, clientY: 5 }); await userEvent.click(await screen.findByRole("menuitem", { name: itemLabel })); } +async function backHome() { + const box = await screen.findByRole("dialog"); + await userEvent.click(within(box).getByRole("button", { name: t("bringBack") })); +} + async function moveTo(rowLabel: string, destination: string) { fireEvent.contextMenu(menuFor(rowLabel), { clientX: 5, clientY: 5 }); await userEvent.click(await screen.findByRole("menuitem", { name: t("moveTo") })); @@ -466,6 +481,7 @@ describe("archiving and bringing back a document", () => { expect(countBadge("Work")).toBe(""); await chooseFor("Report", t("bringBack")); + await backHome(); await waitFor(() => expect(countBadge("Work")).toBe("1")); expect( @@ -473,6 +489,134 @@ describe("archiving and bringing back a document", () => { ).toBeTruthy(); expect(screen.queryByRole("list", { name: t("archived") })).toBeNull(); }); + + it("asks where an unarchived document goes, and takes the answer over where it was", async () => { + const folder = seedFolder({ name: "Work" }); + const doc = seedDoc({ title: "Report", folder: folder.id, archived: true }); + await boot(); + + await chooseFor("Report", t("bringBack")); + await userEvent.click( + await screen.findByRole("radio", { name: new RegExp(`^${t("backToNone")}`) }), + ); + await userEvent.click( + within(screen.getByRole("dialog")).getByRole("button", { name: t("bringBack") }), + ); + + await waitFor(() => expect(store.docs.find((one) => one.id === doc.id)?.folder).toBeNull()); + expect(countBadge("Work")).toBe(""); + }); + + it("keeps the folders a lone archived document hung from, inside the archive", async () => { + const folder = seedFolder({ name: "Work" }); + seedDoc({ title: "Report", folder: folder.id, archived: true }); + await boot(); + + const shelf = within(screen.getByRole("list", { name: t("archived") })); + expect(shelf.getByText("Work")).toBeTruthy(); + expect(shelf.getByText(t("folderTrace"))).toBeTruthy(); + expect(screen.getByRole("button", { name: "Work" })).toBeTruthy(); + }); + + it("hangs the document from the folder that was made again under the same name", async () => { + const folder = seedFolder({ name: "Packaging" }); + const doc = seedDoc({ title: "Report", archived: true, folderWas: ["Packaging"] }); + await boot(); + + await chooseFor("Report", t("bringBack")); + await userEvent.click( + within(screen.getByRole("dialog")).getByRole("button", { name: t("bringBack") }), + ); + + await waitFor(() => + expect(store.docs.find((one) => one.id === doc.id)?.folder).toBe(folder.id), + ); + expect(store.folders.filter((one) => one.name === "Packaging")).toHaveLength(1); + }); + + it("takes the agent's mark off a document the person puts away", async () => { + const doc = seedDoc({ + title: "Handover", + flagged: { at: "2026-09-23T10:00:00Z", said: "The beta channel was retired." }, + }); + await boot(); + + await chooseFor(/^Handover/, t("putAway")); + + await waitFor(() => expect(store.docs.find((one) => one.id === doc.id)?.archived).toBe(true)); + unfoldAll(); + const shelf = within(screen.getByRole("list", { name: t("archived") })); + expect(shelf.queryByTitle(t("docFlagged"))).toBeNull(); + }); + + it("offers to make the folder again when it went while the document waited", async () => { + const doc = seedDoc({ title: "Report", archived: true, folderWas: ["Packaging"] }); + await boot(); + + const shelf = within(screen.getByRole("list", { name: t("archived") })); + expect(shelf.getByText("Packaging")).toBeTruthy(); + + await chooseFor("Report", t("bringBack")); + await userEvent.click( + within(screen.getByRole("dialog")).getByRole("button", { name: t("bringBack") }), + ); + + await waitFor(() => expect(store.folders.some((one) => one.name === "Packaging")).toBe(true)); + const made = store.folders.find((one) => one.name === "Packaging"); + expect(store.docs.find((one) => one.id === doc.id)?.folder).toBe(made?.id); + }); +}); + +describe("what an agent says about a document", () => { + it("marks the row it speaks of, and leaves the document where it was", async () => { + const folder = seedFolder({ name: "Work" }); + seedDoc({ + title: "Handover", + folder: folder.id, + flagged: { at: "2026-09-23T10:00:00Z", said: "The beta channel was retired." }, + }); + await boot(); + + const row = screen.getByRole("button", { name: /^Handover/ }).parentElement as HTMLElement; + expect(within(row).getByTitle(t("docFlagged"))).toBeTruthy(); + const branch = screen.getByRole("button", { name: "Work" }).closest("li") as HTMLElement; + expect(within(branch).getByRole("button", { name: /^Handover/ })).toBeTruthy(); + }); + + it("says what the agent said, and takes the mark off without touching the document", async () => { + const doc = seedDoc({ + title: "Handover", + flagged: { at: "2026-09-23T10:00:00Z", said: "The beta channel was retired." }, + }); + await boot(); + + await userEvent.click(screen.getByRole("button", { name: /^Handover/ })); + expect(await screen.findByText("The beta channel was retired.")).toBeTruthy(); + + await userEvent.click(screen.getByRole("button", { name: t("unflagIt") })); + + await waitFor(() => + expect(store.docs.find((one) => one.id === doc.id)?.flagged).toBeUndefined(), + ); + expect(store.docs.some((one) => one.id === doc.id)).toBe(true); + }); + + it("puts a marked document away when the person says so, and the mark goes quiet", async () => { + const doc = seedDoc({ + title: "Handover", + flagged: { at: "2026-09-23T10:00:00Z", said: "The beta channel was retired." }, + }); + await boot(); + + await userEvent.click(screen.getByRole("button", { name: /^Handover/ })); + await screen.findByTestId("editor"); + await userEvent.click( + within(screen.getByRole("main")).getByRole("button", { name: t("putAway") }), + ); + + await waitFor(() => expect(store.docs.find((one) => one.id === doc.id)?.archived).toBe(true)); + expect(screen.queryByText("The beta channel was retired.")).toBeNull(); + }); }); describe("putting a whole folder away", () => { diff --git a/app/src/ui/About.tsx b/app/src/ui/About.tsx index 2fd1f5ae..aab2ae94 100644 --- a/app/src/ui/About.tsx +++ b/app/src/ui/About.tsx @@ -160,6 +160,8 @@ export default function About({ {newer.installs ? ( t("updateAsk") + ) : newer.route === "store" ? ( + t("updateLanded") ) : ( {fill("updateBrewCli", newer.package ?? "tisty")} )} diff --git a/app/src/ui/Docs.tsx b/app/src/ui/Docs.tsx index 51663dfd..4ac07a23 100644 --- a/app/src/ui/Docs.tsx +++ b/app/src/ui/Docs.tsx @@ -12,6 +12,7 @@ import { docLock, docOrder, docRead, + docUnflag, docWrite, type Filed, type Folded, @@ -27,6 +28,7 @@ import { filed, named, pagesOf, under } from "../paging"; import { crowd, ending, MANY, weighed } from "../previews"; import { saidPlainly } from "../refusal"; import { busy, holds, queued } from "../saving"; +import { clientNamed } from "../who"; import Beside, { trailed } from "./Beside"; import Contents from "./Contents"; import Modal from "./Modal"; @@ -87,6 +89,8 @@ interface Props { onTag?: (tag: string) => void; onOwned?: (id: string) => void; onShown?: (file: string | null) => void; + onDrop?: (doc: Filed) => void; + onBack?: (doc: Filed) => void; fresh?: number; } @@ -105,6 +109,8 @@ export default function Docs({ onTag, onOwned, onShown, + onDrop, + onBack, fresh = 0, }: Props) { const [open, setOpen] = useState(null); @@ -700,15 +706,62 @@ export default function Docs({ {t("docShelved")} +
+ )} + {own?.flagged && open && !shelved && !own.pageOf && ( +
+ + + {clientNamed(own.flagged.via) + ? fill("docFlaggedBy", clientNamed(own.flagged.via) as string) + : t("docFlagged")} + + {stamped(own.flagged.at)} + + {onDrop && !own.locked && ( + + )} + + {own.flagged.said}
)} {bolted && !shelved && open && ( diff --git a/app/src/ui/Sidebar.tsx b/app/src/ui/Sidebar.tsx index d7292492..f7f25a48 100644 --- a/app/src/ui/Sidebar.tsx +++ b/app/src/ui/Sidebar.tsx @@ -75,7 +75,7 @@ export default function Sidebar({
-
+
diff --git a/crates/tisty-cli/src/main.rs b/crates/tisty-cli/src/main.rs index 73a6ee01..574f8494 100644 --- a/crates/tisty-cli/src/main.rs +++ b/crates/tisty-cli/src/main.rs @@ -1,3 +1,5 @@ +#![recursion_limit = "256"] + mod app; mod cmd; mod filter; diff --git a/crates/tisty-cli/src/mcp.rs b/crates/tisty-cli/src/mcp.rs index 700210e4..5115524f 100644 --- a/crates/tisty-cli/src/mcp.rs +++ b/crates/tisty-cli/src/mcp.rs @@ -121,6 +121,13 @@ to consult. Writing one creates no task: if something has to happen, propose it. what is written already and the folders it is kept in; you can make a folder and file documents \ into it, but you can never delete or rename one. +A document you find has had its day — a handover for a flow that was retired, notes for a \ +decision long taken — is one you can mark with `flag_doc`, saying what makes it old and how you \ +know. The mark changes nothing and hides nothing: the document stays where it is and reads the \ +same, and the person sees the mark when they open it. Archiving it, deleting it and taking the \ +mark off are all theirs, from the window, the way finishing a task is. `docs` and `read_doc` say \ +which documents carry one, so the same mark is never left twice. + A document can be locked, and a locked one is refused every write: not `write_doc`, not `append_doc`, not `edit_doc`, not `attach`, not hanging a page off it. Its pages are shut with it — `page_doc` neither hangs one off it nor takes one out — and a page is never locked on its own. Filing it in a folder and putting it away still work: what the lock guards is what the document says and what it holds. `docs` and `read_doc` both say so, so you can see it before you try. Only the person can unlock it, from the window — there is no tool for it here, on purpose. A lock is not the archive, though neither one is written in: an archived document is finished, a locked one is guarded. Bring it back with `archive_doc` and it writes again; a lock only the person can lift, from the window. A whole folder can be in the archive too, and then everything under it is — every subfolder, every document, every page — without any of them being marked one by one. What the archive reaches that way is read, exported and packed as always, and written by nobody: no `write_doc`, no `append_doc`, no `edit_doc`, no `attach`, no `page_doc`, no `file_doc` in or out of it, and nothing new goes into that folder — `write_doc` with it as `folder`, `import_doc`, and `folder` naming it as `inside` are all refused, as is changing how it looks. A document in there has no door of its own: `archive_doc` will not hand it back, because only the folder can be brought back, and only by the person from the window. Its own mark is kept untouched while it waits, so a document somebody had archived by hand stays archived when the folder returns. @@ -507,6 +514,7 @@ fn called(paths: &Paths, params: &Value) -> Result { "outline_doc" => outline_doc(paths, &args), "docs" => papers(paths, &args), "archive_doc" => archive_doc(paths, &args), + "flag_doc" => flag_doc(paths, &args), "export_doc" => export_doc(paths, &args), "import_doc" => import_doc(paths, &args), "file_doc" => file_doc(paths, &args), @@ -3416,6 +3424,9 @@ fn papers(paths: &Paths, args: &Value) -> Result { if state.shut(one.id) { kept_of.insert("locked".into(), json!(true)); } + if one.flagged.is_some() && !state.held_away(one) { + kept_of.insert("flagged".into(), json!(true)); + } if let Some(card) = card { kept_of.insert("words".into(), json!(card.words)); if !card.outline.is_empty() { @@ -3481,8 +3492,13 @@ fn papers(paths: &Paths, args: &Value) -> Result { } else { "" }; + let marked = if one["flagged"] == json!(true) { + ", marked as one that has had its day" + } else { + "" + }; format!( - "{} — {} ({where_at}{holds}{put_away}{shut})", + "{} — {} ({where_at}{holds}{put_away}{shut}{marked})", said(one, "doc"), said(one, "title") ) @@ -4127,6 +4143,71 @@ fn archive_doc(paths: &Paths, args: &Value) -> Result { )) } +fn flag_doc(paths: &Paths, args: &Value) -> Result { + let Some(which) = text(args, "doc") else { + return Err(Refused::Tool( + "marking a document needs its `doc` name.".into(), + )); + }; + let Some(body) = text(args, "body") else { + return Err(Refused::Tool( + "marking a document needs a `body`: what makes it old and how you know. Without it \ + the person has only your word and nothing to weigh it against." + .into(), + )); + }; + let (state, mut store) = opened(paths)?; + let Some(kept) = state.docs.values().find(|one| one.file == which) else { + return Err(Refused::Tool(format!( + "no document here is called {which:?}. `docs` lists them all." + ))); + }; + if let Some(up) = kept.page_of.and_then(|up| named_doc(&state, up)) { + return Err(Refused::Tool(format!( + "{which} is a page of {up}, and a page is weighed with the document that holds \n it. Mark {up} instead." + ))); + } + if state.held_away(kept) { + return Err(Refused::Tool(format!( + "{which} is already in the archive, so it is out of the way. Nothing to mark." + ))); + } + if let Some(already) = &kept.flagged { + let who = match already.by == *store.device() && already.via == speaking_through() { + true => "you".to_string(), + false => already + .via + .as_deref() + .map(tisty_core::agent::client_named) + .unwrap_or_else(|| "an assistant".to_string()), + }; + return Err(Refused::Tool(format!( + "{who} already marked {which} on {}, and the person has not looked yet. Marking \ + it again would only say the same thing twice.", + when(already.at) + ))); + } + let id = kept.id; + let me = store.device().clone(); + store + .append(Op::DocFlag { + id, + d: tisty_core::event::Flag::new(body) + .said_by(jiff::Timestamp::now(), me) + .through(speaking_through()), + }) + .map_err(hitch)?; + + Ok(told( + format!( + "Marked {which} as one that has had its day. It is untouched and still reads the \ + same: the person sees the mark when they open it, and archiving it, deleting \ + it or taking the mark off are all theirs." + ), + json!({ "doc": which, "flagged": true }), + )) +} + fn file_doc(paths: &Paths, args: &Value) -> Result { let Some(which) = text(args, "doc") else { return Err(Refused::Tool( @@ -4930,6 +5011,12 @@ fn read_doc(paths: &Paths, args: &Value) -> Result { if state.shut(kept.id) { kept_of.insert("locked".into(), json!(true)); } + if let Some(mark) = &kept.flagged { + kept_of.insert( + "flagged".into(), + json!({ "at": mark.at.to_string(), "said": mark.body }), + ); + } match part_asked(&body, args)? { Part::Outline => { @@ -4961,9 +5048,16 @@ fn read_doc(paths: &Paths, args: &Value) -> Result { kept_of.insert("next".into(), json!(next)); kept_of.insert("whole".into(), json!(false)); } - let said = match away { - true => format!("(This document is put away — the person archived it.)\n\n{part}"), - false => part, + let said = match (away, kept.flagged.as_ref()) { + (true, _) => { + format!("(This document is put away — the person archived it.)\n\n{part}") + } + (false, Some(mark)) => format!( + "(An assistant marked this as one that has had its day on {}: {})\n\n{part}", + when(mark.at), + mark.body + ), + (false, None) => part, }; Ok(told(said, Value::Object(kept_of))) } @@ -5135,7 +5229,7 @@ fn papers_matching( scope: tisty_core::view::Scope, most: usize, ) -> Vec { - let here: std::collections::HashMap)> = state + let here: std::collections::HashMap, bool)> = state .docs .values() .filter(|one| match scope { @@ -5149,6 +5243,7 @@ fn papers_matching( ( state.held_away(one), one.page_of.and_then(|up| named_doc(state, up)), + one.flagged.is_some() && !state.held_away(one), ), ) }) @@ -5164,13 +5259,15 @@ fn papers_matching( }) .into_iter() .map(|one| { - let (archived, page_of) = here.get(&one.id).cloned().unwrap_or((false, None)); + let (archived, page_of, flagged) = + here.get(&one.id).cloned().unwrap_or((false, None, false)); json!({ "doc": one.id, "title": one.title, "line": one.line, "page_of": page_of, "archived": archived, + "flagged": flagged, }) }) .collect() @@ -5711,6 +5808,21 @@ fn tools() -> Value { "required": ["doc"] })) }, + { + "name": "flag_doc", + "title": "Say a document has had its day", + "description": "Mark a document you found is no longer worth keeping — a handover for a flow that was retired, notes for a decision already taken — and say in `body` what makes it old and how you know. The mark changes nothing: the document reads the same, stays where it is, and the person sees the mark when they open it. What happens next is theirs alone: archive it, delete it, or take the mark off. There is no tool here for any of those, on purpose. One mark at a time — a document already marked is refused until the person has looked.", + "inputSchema": shaped(json!({ + "properties": { + "doc": named_doc_field(), + "body": { + "type": "string", + "description": "What makes it old and how you know, in the person's language: what it describes that no longer exists, and what replaced it" + } + }, + "required": ["doc", "body"] + })) + }, { "name": "file_doc", "title": "Put a document in a folder", diff --git a/crates/tisty-cli/tests/mcp.rs b/crates/tisty-cli/tests/mcp.rs index d5ff1ed9..404f4887 100644 --- a/crates/tisty-cli/tests/mcp.rs +++ b/crates/tisty-cli/tests/mcp.rs @@ -315,6 +315,7 @@ fn there_is_no_tool_for_closing_dropping_or_deleting() { "import_doc", "export_doc", "archive_doc", + "flag_doc", "file_doc", "page_doc", "folder", @@ -3477,6 +3478,92 @@ fn what_cannot_survive_the_editor_never_reaches_a_document_that_exists() { ); } +#[test] +fn marking_a_document_leaves_it_where_it_is_and_says_so_in_the_listing() { + let served = Served::new(); + served.cli(&["agent", "--on"]); + let doc = wrote_paper(&served, "# Handover\n\nThe beta channel."); + + let marked = served.call( + "flag_doc", + serde_json::json!({ "doc": &doc, "body": "the beta channel was retired in 0.1.0" }), + ); + assert!(marked["result"]["isError"].is_null(), "{marked}"); + assert_eq!(marked["result"]["structuredContent"]["flagged"], true); + + let read = served.call("read_doc", serde_json::json!({ "doc": &doc })); + assert_eq!( + read["result"]["structuredContent"]["flagged"]["said"], + "the beta channel was retired in 0.1.0", + "{read}" + ); + assert!( + read["result"]["structuredContent"]["archived"].is_null(), + "marking it must not put it away: {read}" + ); + + let listed = served.call("docs", serde_json::json!({})); + let here = listed["result"]["structuredContent"]["docs"] + .as_array() + .unwrap() + .iter() + .find(|one| one["doc"] == serde_json::json!(doc)) + .expect("the document left the listing") + .clone(); + assert_eq!(here["flagged"], true, "{listed}"); +} + +#[test] +fn a_document_already_marked_is_not_marked_again_until_the_person_looks() { + let served = Served::new(); + served.cli(&["agent", "--on"]); + let doc = wrote_paper(&served, "# Handover\n\nThe beta channel."); + served.call( + "flag_doc", + serde_json::json!({ "doc": &doc, "body": "the beta channel was retired" }), + ); + + let again = served.call( + "flag_doc", + serde_json::json!({ "doc": &doc, "body": "it is still of no use" }), + ); + + assert_eq!(again["result"]["isError"], true, "{again}"); + let text = again["result"]["content"][0]["text"].as_str().unwrap(); + assert!(text.contains("already marked"), "{again}"); +} + +#[test] +fn marking_needs_the_why_and_is_refused_on_what_the_archive_holds() { + let served = Served::new(); + served.cli(&["agent", "--on"]); + let doc = wrote_paper(&served, "# Handover\n\nThe beta channel."); + + let bare = served.call("flag_doc", serde_json::json!({ "doc": &doc })); + assert_eq!(bare["result"]["isError"], true, "{bare}"); + assert!( + bare["result"]["content"][0]["text"] + .as_str() + .unwrap() + .contains("`body`"), + "{bare}" + ); + + served.call("archive_doc", serde_json::json!({ "doc": &doc })); + let away = served.call( + "flag_doc", + serde_json::json!({ "doc": &doc, "body": "it has had its day" }), + ); + assert_eq!(away["result"]["isError"], true, "{away}"); + assert!( + away["result"]["content"][0]["text"] + .as_str() + .unwrap() + .contains("archive"), + "{away}" + ); +} + fn wrote_paper(served: &Served, body: &str) -> String { served.call("write_doc", serde_json::json!({ "body": body }))["result"]["structuredContent"] ["doc"] diff --git a/crates/tisty-core/src/cache.rs b/crates/tisty-core/src/cache.rs index 80402236..abd7f15a 100644 --- a/crates/tisty-core/src/cache.rs +++ b/crates/tisty-core/src/cache.rs @@ -10,7 +10,7 @@ use crate::{ }; /// Tied to the event schema: an older build then misses the cache and meets the version guard. -const SCHEMA: i64 = crate::event::SCHEMA_VERSION as i64 + 5; +const SCHEMA: i64 = crate::event::SCHEMA_VERSION as i64 + 6; pub struct Cache { db: Connection, diff --git a/crates/tisty-core/src/event.rs b/crates/tisty-core/src/event.rs index 88c01100..e4459dd5 100644 --- a/crates/tisty-core/src/event.rs +++ b/crates/tisty-core/src/event.rs @@ -1,9 +1,9 @@ mod op; pub use op::{ - ALIAS_AT_MOST, Body, DeviceKind, DocAdd, Filed, FolderAdd, KNOWN_OPS, ListAdd, LogAdd, LogEdit, - Look, Name, Op, Resolve, Said, Signature, StepAdd, StepRef, StepReorder, StepText, Stitch, - TaskAdd, TaskMove, TaskPatch, + ALIAS_AT_MOST, Body, DeviceKind, DocAdd, Filed, Flag, FolderAdd, KNOWN_OPS, ListAdd, LogAdd, + LogEdit, Look, Name, Op, Resolve, Said, Signature, StepAdd, StepRef, StepReorder, StepText, + Stitch, TaskAdd, TaskMove, TaskPatch, }; use serde::{Deserialize, Serialize}; @@ -121,6 +121,8 @@ impl Event { | Op::DocSigned { id, .. } | Op::DocArchive { id } | Op::DocUnarchive { id } + | Op::DocFlag { id, .. } + | Op::DocUnflag { id } | Op::DocLock { id } | Op::DocUnlock { id } => Some(*id), Op::DeviceJoin { .. } diff --git a/crates/tisty-core/src/event/op.rs b/crates/tisty-core/src/event/op.rs index 9727ddb2..e449db93 100644 --- a/crates/tisty-core/src/event/op.rs +++ b/crates/tisty-core/src/event/op.rs @@ -67,6 +67,8 @@ pub const KNOWN_OPS: &[&str] = &[ "doc.delete", "doc.archive", "doc.unarchive", + "doc.flag", + "doc.unflag", "doc.lock", "doc.unlock", "doc.signed", @@ -169,6 +171,10 @@ pub enum Op { DocArchive { id: DocId }, #[serde(rename = "doc.unarchive")] DocUnarchive { id: DocId }, + #[serde(rename = "doc.flag")] + DocFlag { id: DocId, d: Flag }, + #[serde(rename = "doc.unflag")] + DocUnflag { id: DocId }, #[serde(rename = "doc.lock")] DocLock { id: DocId }, #[serde(rename = "doc.unlock")] @@ -233,6 +239,8 @@ impl Op { matches!( self, Op::DocSaid { .. } + | Op::DocFlag { .. } + | Op::DocUnflag { .. } | Op::Signed { .. } | Op::DocSigned { .. } | Op::DeviceHost { .. } @@ -250,7 +258,11 @@ impl Op { pub fn is_optional(&self) -> bool { matches!( self, - Op::DocSaid { .. } | Op::Signed { .. } | Op::DeviceHost { .. } + Op::DocSaid { .. } + | Op::Signed { .. } + | Op::DeviceHost { .. } + | Op::DocFlag { .. } + | Op::DocUnflag { .. } ) } @@ -295,6 +307,8 @@ impl Op { Op::DocSigned { d, .. } => Op::DocSigned { id, d }, Op::DocDelete { .. } => Op::DocDelete { id }, Op::DocArchive { .. } => Op::DocArchive { id }, + Op::DocFlag { d, .. } => Op::DocFlag { id, d }, + Op::DocUnflag { .. } => Op::DocUnflag { id }, Op::DocUnarchive { .. } => Op::DocUnarchive { id }, Op::DocLock { .. } => Op::DocLock { id }, Op::DocUnlock { .. } => Op::DocUnlock { id }, @@ -333,6 +347,10 @@ impl Op { d.body = one(d.body); Op::TaskLogEdit { id, d } } + Op::DocFlag { id, mut d } => { + d.body = one(d.body); + Op::DocFlag { id, d } + } Op::StepAdd { id, mut d } => { d.text = one(d.text); Op::StepAdd { id, d } @@ -421,6 +439,8 @@ impl Op { | Op::DocDelete { id } | Op::DocArchive { id } | Op::DocUnarchive { id } + | Op::DocFlag { id, .. } + | Op::DocUnflag { id } | Op::DocLock { id } | Op::DocUnlock { id } => Some(*id), Op::DeviceJoin { .. } @@ -594,6 +614,39 @@ impl Resolve { } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Flag { + pub body: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub by: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub via: Option, +} + +impl Flag { + pub fn new(body: impl Into) -> Self { + Self { + body: body.into(), + at: None, + by: None, + via: None, + } + } + + pub fn said_by(mut self, at: jiff::Timestamp, by: DeviceId) -> Self { + self.at = Some(at); + self.by = Some(by); + self + } + + pub fn through(mut self, via: Option) -> Self { + self.via = via; + self + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct StepAdd { pub step: StepId, diff --git a/crates/tisty-core/src/model.rs b/crates/tisty-core/src/model.rs index ba3619a6..23c544a3 100644 --- a/crates/tisty-core/src/model.rs +++ b/crates/tisty-core/src/model.rs @@ -9,7 +9,7 @@ mod tag; mod task; pub use date::DateSpec; -pub use folder::{DEEPEST, DocId, FOLDER_NAME_AT_MOST, Folder, FolderId, Kept}; +pub use folder::{DEEPEST, DocId, FOLDER_NAME_AT_MOST, Flagged, Folder, FolderId, Kept}; pub use list::{List, ListId, first_lists, sown, spoken}; pub use repeat::{Cadence, From, Repeat, Unit}; pub use tag::{AGENT_TAG, InvalidTag, Tag}; diff --git a/crates/tisty-core/src/model/folder.rs b/crates/tisty-core/src/model/folder.rs index 59a5e7c1..992df126 100644 --- a/crates/tisty-core/src/model/folder.rs +++ b/crates/tisty-core/src/model/folder.rs @@ -40,6 +40,15 @@ impl Folder { } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Flagged { + pub at: jiff::Timestamp, + pub by: crate::event::DeviceId, + pub body: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub via: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Kept { pub id: DocId, @@ -75,6 +84,10 @@ pub struct Kept { pub locked: bool, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tags: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub flagged: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub folder_was: Option>, } #[cfg(test)] @@ -110,6 +123,8 @@ mod tests { archived: false, locked: false, edited_by: None, + flagged: None, + folder_was: None, }; let json = serde_json::to_string(&kept).unwrap(); diff --git a/crates/tisty-core/src/state.rs b/crates/tisty-core/src/state.rs index 626fdd21..91fa11e6 100644 --- a/crates/tisty-core/src/state.rs +++ b/crates/tisty-core/src/state.rs @@ -155,7 +155,22 @@ impl State { } } - fn shelve(&mut self, id: DocId, away: bool) { + fn named_trail(&self, at: FolderId) -> Option> { + let mut names = Vec::new(); + let mut up = Some(at); + // An ancestor can be missing when a delete from another machine lands before the add + // that named it, and a partial way down still says more than nothing. + while let Some(folder) = up.and_then(|id| self.folders.get(&id)) { + names.insert(0, folder.name.clone()); + up = folder.parent; + if names.len() > crate::model::DEEPEST { + break; + } + } + (!names.is_empty()).then_some(names) + } + + fn shelve(&mut self, id: DocId, away: bool, by_person: bool) { if self.docs.get(&id).is_some_and(|one| one.page_of.is_some()) { return; } @@ -168,6 +183,11 @@ impl State { for one in pages.into_iter().chain(std::iter::once(id)) { if let Some(doc) = self.docs.get_mut(&one) { doc.archived = away; + // Putting it away is an answer to the mark, but only when the hand is the + // person's: an assistant archiving one of its own marks would bury it. + if away && by_person { + doc.flagged = None; + } } } } @@ -401,6 +421,7 @@ impl State { // would let all of it back out at once, so the mark is written down before the // anchor goes — here, where a delete arriving from another machine lands too. let away = self.folder_away(*id); + let gone = self.named_trail(*id); self.folders.remove(id); self.tombstones.insert(*id); let orphaned: Vec = self @@ -419,6 +440,9 @@ impl State { if doc.folder == Some(*id) { doc.folder = None; doc.archived = doc.archived || away; + if doc.archived { + doc.folder_was = gone.clone(); + } } } } @@ -457,6 +481,8 @@ impl State { .and_then(|one| one.tags.clone()) .unwrap_or_default(), ), + flagged: None, + folder_was: None, }, ); } @@ -504,6 +530,8 @@ impl State { if let Some((folder, archived)) = under { doc.folder = folder; doc.archived = archived; + doc.folder_was = None; + doc.flagged = None; doc.locked = false; doc.order = beside.unwrap_or_else(|| doc.order.clone()); } @@ -520,6 +548,7 @@ impl State { && doc.page_of.is_none() { doc.folder = folder; + doc.folder_was = None; } // Pages live where their document lives, and follow it without being told. if let Some(under) = self.docs.get(id).filter(|one| one.page_of.is_none()) { @@ -527,6 +556,7 @@ impl State { for one in self.docs.values_mut() { if one.page_of == Some(parent) { one.folder = folder; + one.folder_was = None; } } } @@ -565,10 +595,29 @@ impl State { } Op::FolderArchive { id } => self.shelf(*id, true), Op::FolderUnarchive { id } => self.shelf(*id, false), - Op::DocArchive { id } => self.shelve(*id, true), + Op::DocArchive { id } => { + let by_person = !self.assistants.contains(&event.device); + self.shelve(*id, true, by_person) + } Op::DocLock { id } => self.bolt(*id, true), Op::DocUnlock { id } => self.bolt(*id, false), - Op::DocUnarchive { id } => self.shelve(*id, false), + Op::DocUnarchive { id } => self.shelve(*id, false, true), + Op::DocFlag { id, d } => { + let said = crate::model::Flagged { + at: d.at.unwrap_or(event.timestamp), + by: d.by.clone().unwrap_or_else(|| event.device.clone()), + body: d.body.clone(), + via: d.via.clone().or_else(|| event.via.clone()), + }; + if let Some(doc) = self.docs.get_mut(id) { + doc.flagged = Some(said); + } + } + Op::DocUnflag { id } => { + if let Some(doc) = self.docs.get_mut(id) { + doc.flagged = None; + } + } Op::DeviceJoin { d, k } => { self.dropped.remove(d); self.devices.insert(d.clone()); @@ -5571,6 +5620,268 @@ mod tests { assert!(!state.docs.contains_key(&march)); } + #[test] + fn what_an_assistant_marked_carries_the_hand_that_said_it() { + let mut state = State::default(); + let one = doc(&mut state, "a3f1-0001", None); + + let mut said = ev( + 2, + "dev_agent", + Op::DocFlag { + id: one, + d: crate::event::Flag::new("the beta channel was retired"), + }, + ); + said.via = Some("claude-code".into()); + state.apply(&said); + + let mark = state.docs[&one].flagged.clone().expect("the mark was lost"); + assert_eq!(mark.by, DeviceId("dev_agent".into())); + assert_eq!(mark.via.as_deref(), Some("claude-code")); + assert_eq!(mark.body, "the beta channel was retired"); + + state.apply(&ev(3, "dev_laptop", Op::DocUnflag { id: one })); + assert!(state.docs[&one].flagged.is_none(), "the person took it off"); + } + + #[test] + fn a_document_in_the_archive_remembers_the_folder_that_was_deleted_under_it() { + let mut state = State::default(); + let work = folder(&mut state, "work", None); + let one = doc(&mut state, "a3f1-0001", Some(work)); + state.apply(&ev(2, "a", Op::DocArchive { id: one })); + + state.apply(&ev(3, "a", Op::FolderDelete { id: work })); + + assert_eq!(state.docs[&one].folder, None); + assert_eq!( + state.docs[&one].folder_was.as_deref(), + Some(["work".to_string()].as_slice()) + ); + } + + #[test] + fn the_trace_of_a_deleted_folder_keeps_the_whole_way_down_to_it() { + let mut state = State::default(); + let work = folder(&mut state, "work", None); + let under = folder(&mut state, "clients", Some(work)); + let one = doc(&mut state, "a3f1-0001", Some(under)); + state.apply(&ev(2, "a", Op::DocArchive { id: one })); + + state.apply(&ev(3, "a", Op::FolderDelete { id: under })); + + assert_eq!( + state.docs[&one].folder_was.as_deref(), + Some(["work".to_string(), "clients".to_string()].as_slice()), + "a leaf name alone would point at a folder that never held it" + ); + } + + #[test] + fn a_way_down_with_a_missing_ancestor_keeps_what_is_left_of_it() { + let mut state = State::default(); + let lost = Ulid::generate(); + let under = Ulid::generate(); + state.apply(&ev( + 1, + "a", + Op::FolderAdd { + id: under, + d: crate::event::FolderAdd { + name: "clients".into(), + order: "a0".into(), + parent: Some(lost), + icon: None, + color: None, + }, + }, + )); + let one = doc(&mut state, "a3f1-0001", Some(under)); + state.apply(&ev(2, "a", Op::DocArchive { id: one })); + + state.apply(&ev(3, "a", Op::FolderDelete { id: under })); + + assert_eq!( + state.docs[&one].folder_was.as_deref(), + Some(["clients".to_string()].as_slice()), + "a delete that arrived before the add that named the parent must not wipe the way" + ); + } + + #[test] + fn archiving_a_document_takes_the_marks_of_its_pages_too() { + let mut state = State::default(); + let minutes = doc(&mut state, "a3f1-0001", None); + let march = page(&mut state, "a3f1-0002", minutes); + for one in [minutes, march] { + state.apply(&ev( + 2, + "dev_agent", + Op::DocFlag { + id: one, + d: crate::event::Flag::new("it has had its day"), + }, + )); + } + + state.apply(&ev(3, "a", Op::DocArchive { id: minutes })); + + assert!(state.docs[&minutes].flagged.is_none()); + assert!( + state.docs[&march].flagged.is_none(), + "the page went into the archive with it, so its mark was answered too" + ); + } + + #[test] + fn archiving_aimed_at_a_page_leaves_the_page_exactly_as_it_was() { + let mut state = State::default(); + let minutes = doc(&mut state, "a3f1-0001", None); + let march = page(&mut state, "a3f1-0002", minutes); + state.apply(&ev( + 2, + "dev_agent", + Op::DocFlag { + id: march, + d: crate::event::Flag::new("it has had its day"), + }, + )); + + state.apply(&ev(3, "a", Op::DocArchive { id: march })); + + assert!(!state.docs[&march].archived, "a page stays put"); + assert!( + state.docs[&march].flagged.is_some(), + "nothing happened to it, so nothing answered its mark" + ); + } + + #[test] + fn a_page_keeps_no_trace_of_a_folder_once_its_document_lands_again() { + let mut state = State::default(); + let work = folder(&mut state, "work", None); + let home = folder(&mut state, "home", None); + let minutes = doc(&mut state, "a3f1-0001", Some(work)); + let march = page(&mut state, "a3f1-0002", minutes); + state.apply(&ev(2, "a", Op::DocArchive { id: minutes })); + state.apply(&ev(3, "a", Op::FolderDelete { id: work })); + assert!(state.docs[&march].folder_was.is_some()); + + state.apply(&ev(4, "a", Op::DocUnarchive { id: minutes })); + moved( + &mut state, + minutes, + crate::event::Filed { + folder: Some(Some(home)), + page_of: None, + order: None, + }, + ); + + assert_eq!(state.docs[&march].folder_was, None); + assert_eq!(state.docs[&minutes].folder_was, None); + } + + #[test] + fn an_assistant_that_archives_a_marked_document_does_not_bury_the_mark() { + let mut state = State::default(); + state.assistants.insert(DeviceId("dev_agent".into())); + let one = doc(&mut state, "a3f1-0001", None); + state.apply(&ev( + 2, + "dev_agent", + Op::DocFlag { + id: one, + d: crate::event::Flag::new("it has had its day"), + }, + )); + + state.apply(&ev(3, "dev_agent", Op::DocArchive { id: one })); + assert!( + state.docs[&one].flagged.is_some(), + "only the person answers a mark, and no person was here" + ); + + state.apply(&ev(4, "dev_laptop", Op::DocArchive { id: one })); + assert!(state.docs[&one].flagged.is_none()); + } + + #[test] + fn hanging_a_marked_document_as_a_page_leaves_the_mark_behind() { + let mut state = State::default(); + let diary = doc(&mut state, "a3f1-0001", None); + let one = doc(&mut state, "a3f1-0002", None); + state.apply(&ev( + 2, + "dev_agent", + Op::DocFlag { + id: one, + d: crate::event::Flag::new("it has had its day"), + }, + )); + + moved( + &mut state, + one, + crate::event::Filed { + folder: None, + page_of: Some(Some(diary)), + order: None, + }, + ); + + assert_eq!(state.docs[&one].page_of, Some(diary)); + assert!( + state.docs[&one].flagged.is_none(), + "a page is weighed with the document that holds it, so the mark cannot stay on it" + ); + } + + #[test] + fn the_trace_of_a_deleted_folder_goes_when_the_document_lands_somewhere() { + let mut state = State::default(); + let work = folder(&mut state, "work", None); + let one = doc(&mut state, "a3f1-0001", Some(work)); + state.apply(&ev(2, "a", Op::DocArchive { id: one })); + state.apply(&ev(3, "a", Op::FolderDelete { id: work })); + let home = folder(&mut state, "home", None); + + state.apply(&ev(4, "a", Op::DocUnarchive { id: one })); + assert!( + state.docs[&one].folder_was.is_some(), + "coming out of the archive is not landing: the offer to make the folder again \ + has to survive a filing that fails" + ); + + moved( + &mut state, + one, + crate::event::Filed { + folder: Some(Some(home)), + page_of: None, + order: None, + }, + ); + assert_eq!(state.docs[&one].folder, Some(home)); + assert_eq!(state.docs[&one].folder_was, None); + } + + #[test] + fn a_document_out_of_the_archive_when_its_folder_went_keeps_no_trace() { + let mut state = State::default(); + let work = folder(&mut state, "work", None); + let one = doc(&mut state, "a3f1-0001", Some(work)); + + state.apply(&ev(2, "a", Op::FolderDelete { id: work })); + + assert_eq!(state.docs[&one].folder, None); + assert_eq!( + state.docs[&one].folder_was, None, + "it is out in the open, where the tree already says it has no folder" + ); + } + #[test] fn archiving_a_document_puts_its_pages_away_and_brings_them_back() { let mut state = State::default(); diff --git a/crates/tisty-core/src/tagging.rs b/crates/tisty-core/src/tagging.rs index c3016155..1e4c1ec1 100644 --- a/crates/tisty-core/src/tagging.rs +++ b/crates/tisty-core/src/tagging.rs @@ -250,6 +250,8 @@ mod tests { locked: false, tags: vec![Tag::new("legal").unwrap()], edited_by: None, + flagged: None, + folder_was: None, }; let same = crate::event::Said { diff --git a/crates/tisty-core/src/tidy.rs b/crates/tisty-core/src/tidy.rs index 91e26e39..8c0225b2 100644 --- a/crates/tisty-core/src/tidy.rs +++ b/crates/tisty-core/src/tidy.rs @@ -338,6 +338,8 @@ mod tests { archived: false, locked: false, edited_by: None, + flagged: None, + folder_was: None, }, ); }; diff --git a/crates/tisty-core/src/undo.rs b/crates/tisty-core/src/undo.rs index 6cb18fe7..e1be755a 100644 --- a/crates/tisty-core/src/undo.rs +++ b/crates/tisty-core/src/undo.rs @@ -259,7 +259,11 @@ fn undoing(event: &Event, before: &State) -> Option { Op::FolderUnarchive { id } => Some(Op::FolderArchive { id: *id }), Op::FolderDelete { .. } | Op::DocDelete { .. } => None, - Op::DocLock { .. } | Op::DocUnlock { .. } | Op::DocSaid { .. } => None, + Op::DocLock { .. } + | Op::DocUnlock { .. } + | Op::DocSaid { .. } + | Op::DocFlag { .. } + | Op::DocUnflag { .. } => None, Op::TaskDelete { .. } | Op::ListDelete { .. } => None, diff --git a/crates/tisty-core/tests/roundtrip.rs b/crates/tisty-core/tests/roundtrip.rs index b0460e23..7a600d26 100644 --- a/crates/tisty-core/tests/roundtrip.rs +++ b/crates/tisty-core/tests/roundtrip.rs @@ -406,6 +406,8 @@ fn what_a_note_says_a_body_weighs_is_what_the_file_holds() { archived: false, locked: false, tags: Vec::new(), + flagged: None, + folder_was: None, }), "opening it again is news, so it writes another note" ); diff --git a/crates/tisty-core/tests/telling.rs b/crates/tisty-core/tests/telling.rs index 319b2931..d3fd5d76 100644 --- a/crates/tisty-core/tests/telling.rs +++ b/crates/tisty-core/tests/telling.rs @@ -285,6 +285,24 @@ fn where_an_agent_lives_is_a_line_an_older_build_and_undo_both_walk_past() { ); } +#[test] +fn what_an_assistant_says_about_a_document_is_a_line_an_older_build_walks_past() { + let marked = tisty_core::Op::DocFlag { + id: ulid::Ulid::generate(), + d: tisty_core::event::Flag::new("it has had its day"), + }; + let taken_off = tisty_core::Op::DocUnflag { + id: ulid::Ulid::generate(), + }; + + assert!(marked.is_optional(), "an older build would choke on it"); + assert!(taken_off.is_optional(), "an older build would choke on it"); + assert!( + marked.settles() && taken_off.settles(), + "without this, undo stops at a mark that changes nothing" + ); +} + #[test] fn neither_word_about_a_task_being_done_settles_by_itself() { let said = tisty_core::Op::TaskResolve {