Compare commits
2 Commits
a04d169bd8
...
08d2d804ff
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08d2d804ff | ||
|
|
8f50259efe |
@@ -407,6 +407,7 @@ Cells: link strength (9/3/1/blank). Importance row = Σ(weight × strength).
|
||||
- **Asymmetric file index:** `addFile` deduped by SHA only, `registerUploadedFile` by PATH — so an edit (same path, new SHA) left a stale duplicate entry. **Fixed** — `addFile` now dedups by SHA *and* path (a path is unique), with a regression test in `userRepo.store.spec.ts`. (Full bidirectional index C3 still the longer-term direction.)
|
||||
- **ADR-0001 over-absolute:** it called SHA-keyed references "a fragility". **Refined** by ADR-0002 into two intentional modes (Live/Snapshot).
|
||||
- **`CLAUDE.md` "edit history tracking"** mislabels the visited-repos History; no edit-history feature exists. (Flagged in CONTEXT.md.)
|
||||
- **Snapshot cache not immutable:** editing writes new content under the *viewed* (old) SHA's cache key (`prepareNoteCache` keys by the viewed sha), so a re-opened old-SHA snapshot can serve new content from cache. **Decided fix (ADR-0002):** key the immutable store by the content's *own* SHA (write-once), keep the Path key as the latest pointer. *(Not yet implemented — the C3/C4 cache slice; unlocks the snapshot banner.)*
|
||||
- **Naming drift carried from the language session:** `Flux`→`Igarapé`, `HistoricNotes`→`Index`, `Repetition`→`Card`, `PublicNote*`→`PublishedNote*`, `useNotes()` returns Files not Notes. (Tracked in CONTEXT.md "Flagged ambiguities".)
|
||||
|
||||
---
|
||||
|
||||
@@ -17,4 +17,5 @@ This refines ADR-0001, which framed SHA-keyed references as "a fragility." That
|
||||
## Consequences
|
||||
|
||||
- The system **pre-caches** aggressively (content is cached by both SHA and Path on every fetch / freshness pull) so the pinned version is usually present, including offline.
|
||||
- **Cache shape (C3/C4):** content is cached under two keys — its own **content SHA** (write-once, immutable → the snapshot store) and its **Path** (overwritten with the latest content → the live pointer). Both hold the full content, so the latest survives even if a SHA entry is evicted, maximizing offline availability for any version already encountered. (Today the code violates this by writing edited content under the *viewed* old SHA key; the fix is to write under the content's *new* SHA and never overwrite an existing SHA entry.)
|
||||
- When a Snapshot reference's pinned content is genuinely unavailable (never-fetched + offline — common on a flaky mobile connection), the system **falls back to the most up-to-date cached version and shows a banner** disclosing "this is the latest available, not the exact shared version." Integrity is preserved by **disclosure, not refusal**: the reader is never silently shown different content, but is also never dead-ended on the metro. (This chooses graceful continuity over a hard "unavailable" stop, given mobile is the primary read context.)
|
||||
|
||||
29
src/components/NoteState.spec.ts
Normal file
29
src/components/NoteState.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { mount } from "@vue/test-utils"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import NoteState from "./NoteState.vue"
|
||||
|
||||
describe("NoteState", () => {
|
||||
it("shows a spinner while loading and no retry", () => {
|
||||
const wrapper = mount(NoteState, { props: { status: "loading" } })
|
||||
|
||||
expect(wrapper.find(".loading-spinner").exists()).toBe(true)
|
||||
expect(wrapper.find(".note-state-retry").exists()).toBe(false)
|
||||
})
|
||||
|
||||
it("shows a message and retry button when failed", () => {
|
||||
const wrapper = mount(NoteState, { props: { status: "failed" } })
|
||||
|
||||
expect(wrapper.find(".loading-spinner").exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain("Couldn't load this note")
|
||||
expect(wrapper.find(".note-state-retry").exists()).toBe(true)
|
||||
})
|
||||
|
||||
it("emits retry when the retry button is clicked", async () => {
|
||||
const wrapper = mount(NoteState, { props: { status: "failed" } })
|
||||
|
||||
await wrapper.find(".note-state-retry").trigger("click")
|
||||
|
||||
expect(wrapper.emitted("retry")).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
48
src/components/NoteState.vue
Normal file
48
src/components/NoteState.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ status: "loading" | "failed" }>()
|
||||
defineEmits<{ retry: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="note-state">
|
||||
<span
|
||||
v-if="status === 'loading'"
|
||||
class="loading loading-spinner loading-md"
|
||||
aria-label="Loading note"
|
||||
></span>
|
||||
<template v-else>
|
||||
<p class="note-state-message">Couldn't load this note.</p>
|
||||
<button type="button" class="note-state-retry" @click="$emit('retry')">
|
||||
Retry
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.note-state {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 2rem 1rem;
|
||||
color: var(--color-base-content);
|
||||
opacity: 0.55;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.note-state-message {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.note-state-retry {
|
||||
padding: 0.25rem 0.85rem;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -40,6 +40,10 @@ const EditNote = defineAsyncComponent(
|
||||
() => import("@/modules/note/components/EditNote.vue")
|
||||
)
|
||||
|
||||
const NoteState = defineAsyncComponent(
|
||||
() => import("@/components/NoteState.vue")
|
||||
)
|
||||
|
||||
const props = defineProps<{
|
||||
user: string
|
||||
repo: string
|
||||
@@ -154,10 +158,21 @@ const {
|
||||
})
|
||||
|
||||
const conflictOpen = ref(false)
|
||||
const loadStatus = ref<"loading" | "ready" | "failed">("loading")
|
||||
|
||||
onMounted(async () => {
|
||||
initialRawContent.value = await getRawContent()
|
||||
})
|
||||
const loadNote = async () => {
|
||||
loadStatus.value = "loading"
|
||||
const raw = await getRawContent()
|
||||
if (raw === null) {
|
||||
loadStatus.value = "failed"
|
||||
return
|
||||
}
|
||||
rawContent.value = raw
|
||||
initialRawContent.value = raw
|
||||
loadStatus.value = "ready"
|
||||
}
|
||||
|
||||
onMounted(loadNote)
|
||||
|
||||
watch(
|
||||
path,
|
||||
@@ -412,11 +427,18 @@ const onBadgeClick = async () => {
|
||||
<div v-if="mode === 'edit' && isMarkdown" class="edit">
|
||||
<edit-note :key="editKey" v-model="rawContent" />
|
||||
</div>
|
||||
<template v-else-if="mode === 'read'">
|
||||
<div
|
||||
v-if="mode === 'read'"
|
||||
v-if="displayedContent"
|
||||
class="note-content"
|
||||
v-html="displayedContent"
|
||||
></div>
|
||||
<note-state
|
||||
v-else
|
||||
:status="loadStatus === 'failed' ? 'failed' : 'loading'"
|
||||
@retry="loadNote"
|
||||
/>
|
||||
</template>
|
||||
</section>
|
||||
<linked-notes v-if="hasBacklinks && content" :sha="sha" />
|
||||
<note-conflict-modal
|
||||
|
||||
Reference in New Issue
Block a user