From 168ef0c6f55893fe74b168eb60afc31ccbaf751f Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 15 Sep 2026 17:15:29 +0200 Subject: [PATCH] fix: an emptied selection is no shift anchor, and a deploy leaves no draft for the prompt to compare Co-Authored-By: Claude Opus 5 (1M context) --- .../components/home/homeSelection.dom.test.ts | 59 +++++++++++++++++++ .../components/home/homeSelection.svelte.ts | 5 +- .../(logged)/apps/edit/[...path]/+page.svelte | 4 ++ .../apps_raw/edit/[...path]/+page.svelte | 4 ++ 4 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/components/home/homeSelection.dom.test.ts diff --git a/frontend/src/lib/components/home/homeSelection.dom.test.ts b/frontend/src/lib/components/home/homeSelection.dom.test.ts new file mode 100644 index 0000000000..1e325fa3bd --- /dev/null +++ b/frontend/src/lib/components/home/homeSelection.dom.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { HomeSelection, type BulkItem } from './homeSelection.svelte' + +// A shift-click range resolves its ends through the rendered rows, so these need a +// document: the keys come back from `[data-row-selection-key]` in visual order. +function row(key: string): BulkItem { + return { + key, + kind: 'script', + path: key.slice('script/'.length), + displayPath: key.slice('script/'.length), + summary: '', + canWrite: true, + owner: true, + archived: false, + draftOnly: false, + isDraft: false, + rawApp: false + } +} + +const a = row('script/f/a/one') +const b = row('script/f/a/two') +const c = row('script/f/a/three') + +function rendered(...items: BulkItem[]): HomeSelection { + document.body.innerHTML = items + .map((i) => `
`) + .join('') + const s = new HomeSelection() + s.available = true + for (const i of items) s.register(i) + return s +} + +describe('HomeSelection shift-range', () => { + beforeEach(() => { + document.body.innerHTML = '' + }) + + it('selects the span between the anchor and the clicked row', () => { + const s = rendered(a, b, c) + s.toggle(a) + + s.toggle(c, true) + + expect(s.items.map((i) => i.key)).toEqual([a.key, b.key, c.key]) + }) + + it('does not reach back into a selection the user already emptied', () => { + const s = rendered(a, b, c) + s.toggle(a) + s.toggle(a) + + s.toggle(c, true) + + expect(s.items.map((i) => i.key)).toEqual([c.key]) + }) +}) diff --git a/frontend/src/lib/components/home/homeSelection.svelte.ts b/frontend/src/lib/components/home/homeSelection.svelte.ts index f4c12a5d20..97e2acc9d7 100644 --- a/frontend/src/lib/components/home/homeSelection.svelte.ts +++ b/frontend/src/lib/components/home/homeSelection.svelte.ts @@ -150,7 +150,10 @@ export class HomeSelection { } if (this.selected.has(item.key)) this.selected.delete(item.key) else this.selected.set(item.key, item) - this.anchor = item.key + // Unticking the last row ends selection mode, so the row it was on is not an + // anchor any more: the next shift-click would otherwise reach back and select + // everything between it and the row the user just started from. + this.anchor = this.selected.size === 0 ? undefined : item.key } /** Visual order is read back from the DOM: the tree nests rows and pages them diff --git a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte index 1006fdb780..5252309f90 100644 --- a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte @@ -501,6 +501,10 @@ // (unknown when another landed beside it), and the head is what is deployed, // named by whoever deployed it rather than by the page load's author. draftBaseVersion = version != null ? String(version) : undefined + // The deploy consumed the draft, so the prompt has nothing to compare: without + // this the timestamp fallback reads a load-time draft save against a deploy + // that just happened and opens on a row that no longer exists. + draftSavedAt = undefined if (head != null) { deployedHeadVersion = String(head) deployedBy = headBy diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte index 6667655406..059c2042f3 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte @@ -663,6 +663,10 @@ // than keeping the one it just superseded. parentVersion = version draftBaseVersion = version != null ? String(version) : undefined + // The deploy consumed the draft, so the prompt has nothing to compare: without + // this the timestamp fallback reads a load-time draft save against a deploy + // that just happened and opens on a row that no longer exists. + draftSavedAt = undefined if (head != null) { deployedHeadVersion = String(head) // Named by whoever deployed the head, not by the page load's author.