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
Original file line number Diff line number Diff line change
Expand Up @@ -177,9 +177,17 @@ export interface ImportFolderSuggestionsRequest {
* TombstoneResult names exactly which ids one DeleteCard/DeleteNote
* call soft-deleted -- DeleteCard populates only CardIDs, DeleteNote
* only NoteIDs, so the frontend's undo toast can pass this straight
* back to UndoDelete without re-deriving what it touched.
* back to UndoDelete without re-deriving what it touched. LinksRemoved
* and ChildrenPromoted are the delete's blast radius, counted against
* the state immediately BEFORE this call's own tombstone lands: links
* that were visible and now touch a tombstoned endpoint, and direct
* live children (cards + notes) whose effective parent is about to
* shift past this card. Both stay zero for DeleteNote -- a note can
* carry neither a link endpoint nor a child.
*/
export interface TombstoneResult {
"CardIDs": string[] | null;
"NoteIDs": string[] | null;
"LinksRemoved": number;
"ChildrenPromoted": number;
}
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,12 @@ export function DismissPanel(): $CancellablePromise<void> {
* Channel-gated server-side, not just by the UI: a source-channel
* build must never binary-swap itself, since "source" means the
* running copy IS the update mechanism (pull + rebuild).
*
* goal 0100: a beta install swaps its running app over real, primary
* data, so the swap never proceeds without a fresh restore point. The
* backupRunner seam is called first and its error aborts the update
* outright -- no download, no swap -- same fail-closed posture as the
* digest check below it.
*/
export function DownloadAndInstallUpdate(): $CancellablePromise<void> {
return $Call.ByID(174385789);
Expand Down Expand Up @@ -533,9 +539,9 @@ export function UnassignSummonHotkey(): $CancellablePromise<void> {
}

/**
* UpdateChannel reports the resolved distribution channel ("source" or
* "release") -- the Settings > Updates surface's gate between "Update
* now" and the pull-and-rebuild instructions, and
* UpdateChannel reports the resolved distribution channel ("source",
* "release", or "beta") -- the Settings > Updates surface's gate
* between "Update now" and the pull-and-rebuild instructions, and
* DownloadAndInstallUpdate's own server-side enforcement of the same
* gate.
*/
Expand Down
41 changes: 41 additions & 0 deletions frontend/e2e/atlas-delete-relationships.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { test, expect } from './fixtures/server'
import { deleteViaPageMenu } from './fixtures/atlasPage'
import { openViaFlip } from './fixtures/atlasBoard'

// Split out of atlas.spec.ts (architecture.md's 500-line convention,
// the same split atlas-share.spec.ts/atlas-projections.spec.ts already
// established) once the delete-blast-radius toast case (goal 0103)
// pushed that file over the limit.

// Precise per-card matching, same reasoning as atlas.spec.ts's own
// local copy: a card's own BACK face can legitimately contain another
// card's title in its "<kind> -> <other title>" link row, so aria-label
// carries the exact title instead of a substring match.
function noteCard(page: import('@playwright/test').Page, title: string) {
return page.locator(`[data-testid="atlas-note-card"][aria-label="Flip ${title}"]`)
}

test('deleting a linked card names the blast radius in the undo toast, and undo restores the link edge', async ({ page }) => {
await page.goto('/')
await page.getByRole('link', { name: 'Atlas' }).click()
await expect(page.getByTestId('atlas-board')).toBeVisible()
await expect(page.locator('.react-flow__edge')).not.toHaveCount(0)

// "Getting started" carries exactly one seeded link (to "Ada
// Lovelace") and no children -- the leaf-with-links case.
const gettingStarted = noteCard(page, 'Getting started')
await openViaFlip(gettingStarted)
const overlay = page.locator('[data-component="atlas-card-overlay"]')
await deleteViaPageMenu(page, overlay)
await expect(gettingStarted).toHaveCount(0)

const undoToast = page.getByTestId('atlas-undo-toast')
await expect(undoToast).toBeVisible()
await expect(undoToast).toContainText('Deleted 1')
await expect(undoToast).toContainText('1 link hidden')

await undoToast.getByTestId('atlas-undo-toast-button').click()
await expect(undoToast).toHaveCount(0)
await expect(gettingStarted).toBeVisible()
await expect(page.locator('.react-flow__edge')).not.toHaveCount(0)
})
18 changes: 15 additions & 3 deletions frontend/src/atlas/AtlasUndoToast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,24 @@ import styles from './AtlasUndoToast.module.css'
// selection/creation tray -- AtlasView renders exactly one of these at
// a time, cleared by its own 10s timer, a click, ⌘Z, or a later delete
// finalizing it (useAtlasUndoToast owns that lifecycle; this component
// is pure presentation).
export function AtlasUndoToast({ count, onUndo }: { count: number; onUndo: () => void }) {
// is pure presentation). linksRemoved/childrenPromoted (goal 0103) name
// the delete's blast radius: a segment appends only when its count is
// non-zero, since undo already restores both in full.
export function AtlasUndoToast({
count, linksRemoved, childrenPromoted, onUndo,
}: {
count: number
linksRemoved: number
childrenPromoted: number
onUndo: () => void
}) {
const { t } = useTranslation('atlas')
const segments = [t('board.deletedToast', { count })]
if (linksRemoved > 0) segments.push(t('board.linksHiddenToast', { count: linksRemoved }))
if (childrenPromoted > 0) segments.push(t('board.childrenMovedToast', { count: childrenPromoted }))
return (
<div className={styles.toast} data-testid="atlas-undo-toast" role="status">
<span className={styles.message}>{t('board.deletedToast', { count })}</span>
<span className={styles.message}>{segments.join(' ')}</span>
<Button size="small" variant="invisible" onClick={onUndo} data-testid="atlas-undo-toast-button">
{t('board.undo')}
</Button>
Expand Down
12 changes: 8 additions & 4 deletions frontend/src/atlas/AtlasView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,8 @@ export function AtlasView({ initialCardID }: { initialCardID?: string }) {
// against re-applying it on every later data refresh, which would
// otherwise re-open the overlay even after the user closed it).
const consumedInitialCardID = useRef(false)
// Mirrors the ref as state for the landing gate below -- a ref must
// not be read during render, and the gate needs re-render when the
// deep link resolves.
// State mirror of the ref: the landing gate renders off it, and a
// ref must not be read during render.
const [deepLinkConsumed, setDeepLinkConsumed] = useState(false)

useEffect(() => {
Expand Down Expand Up @@ -450,7 +449,12 @@ export function AtlasView({ initialCardID }: { initialCardID?: string }) {
}}
/>
{undoToast.pending && (
<AtlasUndoToast count={undoToast.pending.count} onUndo={undoToast.undo} />
<AtlasUndoToast
count={undoToast.pending.count}
linksRemoved={undoToast.pending.linksRemoved}
childrenPromoted={undoToast.pending.childrenPromoted}
onUndo={undoToast.undo}
/>
)}
</div>

Expand Down
2 changes: 2 additions & 0 deletions frontend/src/atlas/useAtlasContainmentMenus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ export function useAtlasContainmentMenus({
onDeleted({
CardIDs: results.flatMap((r) => r.CardIDs ?? []),
NoteIDs: results.flatMap((r) => r.NoteIDs ?? []),
LinksRemoved: results.reduce((sum, r) => sum + (r.LinksRemoved ?? 0), 0),
ChildrenPromoted: results.reduce((sum, r) => sum + (r.ChildrenPromoted ?? 0), 0),
})
void refreshAtlas()
})
Expand Down
10 changes: 9 additions & 1 deletion frontend/src/atlas/useAtlasUndoToast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export interface PendingUndo {
cardIDs: string[]
noteIDs: string[]
count: number
linksRemoved: number
childrenPromoted: number
}

// Owns the quick-delete undo toast's whole lifecycle (goal 0093): one
Expand Down Expand Up @@ -37,7 +39,13 @@ export function useAtlasUndoToast() {
clearTimer()
const cardIDs = result.CardIDs ?? []
const noteIDs = result.NoteIDs ?? []
setPending({ cardIDs, noteIDs, count: cardIDs.length + noteIDs.length })
setPending({
cardIDs,
noteIDs,
count: cardIDs.length + noteIDs.length,
linksRemoved: result.LinksRemoved ?? 0,
childrenPromoted: result.ChildrenPromoted ?? 0,
})
timerRef.current = setTimeout(() => setPending(null), TOAST_DURATION_MS)
}

Expand Down
3 changes: 3 additions & 0 deletions frontend/src/locales/en/atlas.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@
"selectionClearHint": "clear",
"dropDuplicateNotice": "Already on the map as “{{title}}” — added again.",
"deletedToast": "Deleted {{count}}",
"linksHiddenToast_one": "· {{count}} link hidden",
"linksHiddenToast_other": "· {{count}} links hidden",
"childrenMovedToast": "· {{count}} moved up",
"undo": "Undo"
},
"jump": {
Expand Down
59 changes: 55 additions & 4 deletions internal/services/atlassvc/atlasservice_tombstone.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,59 @@ const tombstoneGraceWindow = 48 * time.Hour
// TombstoneResult names exactly which ids one DeleteCard/DeleteNote
// call soft-deleted -- DeleteCard populates only CardIDs, DeleteNote
// only NoteIDs, so the frontend's undo toast can pass this straight
// back to UndoDelete without re-deriving what it touched.
// back to UndoDelete without re-deriving what it touched. LinksRemoved
// and ChildrenPromoted are the delete's blast radius, counted against
// the state immediately BEFORE this call's own tombstone lands: links
// that were visible and now touch a tombstoned endpoint, and direct
// live children (cards + notes) whose effective parent is about to
// shift past this card. Both stay zero for DeleteNote -- a note can
// carry neither a link endpoint nor a child.
type TombstoneResult struct {
CardIDs []string
NoteIDs []string
CardIDs []string
NoteIDs []string
LinksRemoved int
ChildrenPromoted int
}

// liveLinkTouchCountLocked counts currently-live links (both endpoints
// live) that touch cardID -- the number of links about to become
// hidden if cardID is tombstoned next. Caller must hold a.mu.
func (a *AtlasService) liveLinkTouchCountLocked(cardID string) int {
tombstoned := make(map[string]bool)
for _, c := range a.cards {
if !c.DeletedAt.IsZero() {
tombstoned[c.ID] = true
}
}
n := 0
for _, l := range a.links {
if tombstoned[l.FromCardID] || tombstoned[l.ToCardID] {
continue
}
if l.FromCardID == cardID || l.ToCardID == cardID {
n++
}
}
return n
}

// directLiveChildCountLocked counts live cards and notes whose stored
// ParentID is exactly cardID -- the direct children about to be
// virtually promoted to cardID's own effective parent. Caller must
// hold a.mu.
func (a *AtlasService) directLiveChildCountLocked(cardID string) int {
n := 0
for _, c := range a.cards {
if c.ParentID == cardID && c.DeletedAt.IsZero() {
n++
}
}
for _, nt := range a.notes {
if nt.ParentID == cardID && nt.DeletedAt.IsZero() {
n++
}
}
return n
}

// liveCardsLocked returns every non-tombstoned card, each carrying its
Expand Down Expand Up @@ -106,6 +155,8 @@ func (a *AtlasService) DeleteCard(id string) (TombstoneResult, error) {
}
previous := a.cards[idx]
wasBuiltIn := previous.BuiltIn
linksRemoved := a.liveLinkTouchCountLocked(id)
childrenPromoted := a.directLiveChildCountLocked(id)
now := time.Now()
a.cards[idx].DeletedAt = now
a.cards[idx].UpdatedAt = now
Expand Down Expand Up @@ -139,7 +190,7 @@ func (a *AtlasService) DeleteCard(id string) (TombstoneResult, error) {
return TombstoneResult{}, fmt.Errorf("save card deletion: %w", perr)
}
dataevent.Emit("atlas", id)
return TombstoneResult{CardIDs: []string{id}}, nil
return TombstoneResult{CardIDs: []string{id}, LinksRemoved: linksRemoved, ChildrenPromoted: childrenPromoted}, nil
}

// DeleteNote soft-deletes a note -- same tombstone contract as
Expand Down
Loading
Loading