CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 31s
CI & Build / Python tests (push) Successful in 1m5s
CI & Build / Build & push image (push) Successful in 38s
#3127 checklist 12, plus rule 27 — a capability with no surface the operator can touch is not shipped. The step was planned on the premise that nothing read `/api/version`. Two things did, and the state was worse than nothing: - `App.vue` fetched it, wrote `version` into a ref initialised to the literal `"dev"`, and swallowed the error. An instance that could not answer rendered EXACTLY what a healthy local build renders. That is checklist 12's named failure — a blank standing in for `unknown` — in the one readout whose whole job is to say what is running, and it would have made #3298's debugging session no cheaper. - `SettingsView.vue` fetched the same endpoint again on every mount and wrote the result into a local ref no template ever read. A duplicate request whose answer was discarded. So this is not "add a readout"; it is "make the existing one honest, and give it the three fields nobody could see." The readout — Settings → Config, first section, beside the other "what is this instance doing" facts. Three states kept apart, because collapsing any two of them is the defect: not asked yet (tab unopened) nothing answered the values, each ABSENT field as "unknown" the fetch itself failed its own message, with a retry `version` and `channel` prominent, `commit` in full with a copy button so it can be pasted into a `:sha` lookup (rule 145 — the registry's identity and the artifact's own must be checkable against each other), `build` kept because its ABSENCE is the diagnostic part: no ordering key means this build is not in any update order, which is what a local or hand-built image looks like. Absence, not falsiness. The payload omits what it does not know rather than sending `""` or `0` (see `build_version_payload`), so the renderer uses `??` throughout — `build` is a number and `0` is a legitimate ordering key, which `||` would report as unknown. `tests/test_version_readout.py` pins that operator specifically, along with the "no plausible default" property, because `||` is the form a person reaches for by habit. Rule 156 — the fetch carries a deadline. This readout is consulted when an instance is misbehaving, which is exactly when it may never answer; without one the surface sits on "still loading" forever, which is the same blank arrived at from the other direction. `apiGet` gains an OPT-IN `timeoutMs` rather than a default, so no existing call site's behaviour moves. Every other call in the client still has no deadline — reported separately, not fixed here. No frontend test runner exists, so verification is the typecheck lane plus four source-inspection guards in the unit lane, each pinning one property. Also folded in: `plugin/README.md` now leads with the mint script and offers `make` second, since `make` is not installed on every workstation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TcCs1CcQ1ormdnzSshKqvN
411 lines
12 KiB
Vue
411 lines
12 KiB
Vue
<script setup lang="ts">
|
|
import { onMounted, onUnmounted, ref, watch } from "vue";
|
|
import { useRouter } from "vue-router";
|
|
import AppHeader from "@/components/AppHeader.vue";
|
|
import ToastNotification from "@/components/ToastNotification.vue";
|
|
import { useTheme } from "@/composables/useTheme";
|
|
import { useShortcuts } from "@/composables/useShortcuts";
|
|
import { useAuthStore } from "@/stores/auth";
|
|
import { useSettingsStore } from "@/stores/settings";
|
|
import { apiPut } from "@/api/client";
|
|
import { fetchVersion } from "@/api/version";
|
|
|
|
useTheme();
|
|
|
|
const router = useRouter();
|
|
// THREE states, not two (#3127 checklist 12). `null` is "not answered yet" and
|
|
// renders nothing; a string renders; `appVersionFailed` renders its own thing.
|
|
// This used to default to the literal "dev" and swallow the error, which meant
|
|
// an instance that could not answer was indistinguishable from a local build
|
|
// that genuinely reports "dev" — a blank standing in for `unknown`, in the one
|
|
// readout whose whole job is to say what is running.
|
|
const appVersion = ref<string | null>(null);
|
|
const appVersionFailed = ref(false);
|
|
const authStore = useAuthStore();
|
|
const settingsStore = useSettingsStore();
|
|
const { showShortcuts, toggleShortcuts, closeShortcuts } = useShortcuts();
|
|
|
|
function startAppServices() {
|
|
settingsStore.fetchSettings();
|
|
// Sync browser timezone to the server on every login/page load.
|
|
apiPut("/api/settings", { user_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone }).catch(() => {});
|
|
}
|
|
|
|
function stopAppServices() {
|
|
// no-op for now; kept as a hook for future per-session teardown
|
|
}
|
|
|
|
function isInputActive(): boolean {
|
|
const el = document.activeElement;
|
|
if (!el) return false;
|
|
const tag = (el as HTMLElement).tagName;
|
|
return tag === "INPUT" || tag === "TEXTAREA" || (el as HTMLElement).isContentEditable;
|
|
}
|
|
|
|
// Sequence shortcut support (e.g. g+n, g+t)
|
|
let pendingPrefix: string | null = null;
|
|
let prefixTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
function clearPrefix() {
|
|
pendingPrefix = null;
|
|
if (prefixTimeout) clearTimeout(prefixTimeout);
|
|
prefixTimeout = null;
|
|
}
|
|
|
|
function onGlobalKeydown(e: KeyboardEvent) {
|
|
if (!authStore.isAuthenticated) return;
|
|
|
|
// ? — toggle shortcuts overlay (only when not typing)
|
|
if (e.key === "?" && !isInputActive() && !e.ctrlKey && !e.metaKey) {
|
|
toggleShortcuts();
|
|
return;
|
|
}
|
|
|
|
// Escape — progressive: close overlay → unfocus field → go home
|
|
if (e.key === "Escape") {
|
|
clearPrefix();
|
|
if (showShortcuts.value) {
|
|
closeShortcuts();
|
|
return;
|
|
}
|
|
if (isInputActive()) {
|
|
(document.activeElement as HTMLElement).blur();
|
|
return;
|
|
}
|
|
router.push("/");
|
|
return;
|
|
}
|
|
|
|
// All remaining shortcuts: ignore when typing or using modifier keys
|
|
if (isInputActive() || e.ctrlKey || e.metaKey || e.altKey) return;
|
|
|
|
// Complete a g+X sequence
|
|
if (pendingPrefix === "g") {
|
|
clearPrefix();
|
|
switch (e.key) {
|
|
case "h": router.push("/"); break;
|
|
case "n": router.push("/notes"); break;
|
|
case "t": router.push("/"); break;
|
|
case "p": router.push("/projects"); break;
|
|
case "r": router.push("/rules"); break;
|
|
case "g": router.push("/graph"); break;
|
|
case "x": router.push("/trash"); break;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Single-key shortcuts
|
|
switch (e.key) {
|
|
case "g":
|
|
pendingPrefix = "g";
|
|
prefixTimeout = setTimeout(clearPrefix, 1500);
|
|
break;
|
|
case "n":
|
|
router.push("/notes/new");
|
|
break;
|
|
case "t":
|
|
router.push("/tasks/new");
|
|
break;
|
|
case "e": {
|
|
const name = router.currentRoute.value.name;
|
|
if (name === "note-view") {
|
|
router.push(router.currentRoute.value.path + "/edit");
|
|
}
|
|
break;
|
|
}
|
|
case "/":
|
|
e.preventDefault();
|
|
document.dispatchEvent(new CustomEvent("shortcut:focus-search"));
|
|
break;
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
document.addEventListener("keydown", onGlobalKeydown);
|
|
await authStore.checkAuth();
|
|
if (authStore.isAuthenticated) {
|
|
startAppServices();
|
|
}
|
|
try {
|
|
appVersion.value = (await fetchVersion()).version;
|
|
} catch {
|
|
// Not silent any more: the footer says it could not find out, rather than
|
|
// showing a version it never received. The full readout (version, channel,
|
|
// commit, build) lives in Settings → Config.
|
|
appVersionFailed.value = true;
|
|
}
|
|
});
|
|
|
|
watch(
|
|
() => authStore.isAuthenticated,
|
|
(authenticated) => {
|
|
if (authenticated) {
|
|
startAppServices();
|
|
} else {
|
|
stopAppServices();
|
|
}
|
|
}
|
|
);
|
|
|
|
onUnmounted(() => {
|
|
document.removeEventListener("keydown", onGlobalKeydown);
|
|
stopAppServices();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<template v-if="authStore.isAuthenticated">
|
|
<a href="#main-content" class="skip-link">Skip to main content</a>
|
|
<div class="app-shell">
|
|
<AppHeader />
|
|
<div id="main-content" class="app-content">
|
|
<router-view />
|
|
</div>
|
|
<footer class="app-footer">
|
|
<span v-if="appVersion">v{{ appVersion }}</span>
|
|
<span v-else-if="appVersionFailed">version unknown</span>
|
|
</footer>
|
|
</div>
|
|
|
|
<!-- Keyboard shortcuts overlay -->
|
|
<Transition name="shortcuts-fade">
|
|
<div v-if="showShortcuts" class="shortcuts-overlay" @click.self="closeShortcuts">
|
|
<div class="shortcuts-panel">
|
|
<div class="shortcuts-header">
|
|
<h3>Keyboard Shortcuts</h3>
|
|
<button class="shortcuts-close" aria-label="Close keyboard shortcuts" @click="closeShortcuts">×</button>
|
|
</div>
|
|
<div class="shortcuts-body">
|
|
<div class="shortcuts-section">
|
|
<div class="shortcuts-section-title">Navigation</div>
|
|
<div class="shortcut-row">
|
|
<kbd class="shortcut-key">g</kbd>
|
|
<span class="shortcut-key-sep">+</span>
|
|
<kbd class="shortcut-key">h</kbd>
|
|
<span class="shortcut-desc">Home</span>
|
|
</div>
|
|
<div class="shortcut-row">
|
|
<kbd class="shortcut-key">g</kbd>
|
|
<span class="shortcut-key-sep">+</span>
|
|
<kbd class="shortcut-key">n</kbd>
|
|
<span class="shortcut-desc">Notes</span>
|
|
</div>
|
|
<div class="shortcut-row">
|
|
<kbd class="shortcut-key">g</kbd>
|
|
<span class="shortcut-key-sep">+</span>
|
|
<kbd class="shortcut-key">t</kbd>
|
|
<span class="shortcut-desc">Knowledge (tasks)</span>
|
|
</div>
|
|
<div class="shortcut-row">
|
|
<kbd class="shortcut-key">g</kbd>
|
|
<span class="shortcut-key-sep">+</span>
|
|
<kbd class="shortcut-key">p</kbd>
|
|
<span class="shortcut-desc">Projects</span>
|
|
</div>
|
|
<div class="shortcut-row">
|
|
<kbd class="shortcut-key">g</kbd>
|
|
<span class="shortcut-key-sep">+</span>
|
|
<kbd class="shortcut-key">x</kbd>
|
|
<span class="shortcut-desc">Trash</span>
|
|
</div>
|
|
<div class="shortcut-row">
|
|
<kbd class="shortcut-key">Esc</kbd>
|
|
<span class="shortcut-desc">Unfocus field → go home</span>
|
|
</div>
|
|
<div class="shortcut-row">
|
|
<kbd class="shortcut-key">?</kbd>
|
|
<span class="shortcut-desc">Toggle this panel</span>
|
|
</div>
|
|
</div>
|
|
<div class="shortcuts-section">
|
|
<div class="shortcuts-section-title">Create</div>
|
|
<div class="shortcut-row">
|
|
<kbd class="shortcut-key">n</kbd>
|
|
<span class="shortcut-desc">New note</span>
|
|
</div>
|
|
<div class="shortcut-row">
|
|
<kbd class="shortcut-key">t</kbd>
|
|
<span class="shortcut-desc">New task</span>
|
|
</div>
|
|
</div>
|
|
<div class="shortcuts-section">
|
|
<div class="shortcuts-section-title">Lists</div>
|
|
<div class="shortcut-row">
|
|
<kbd class="shortcut-key">/</kbd>
|
|
<span class="shortcut-desc">Focus search bar</span>
|
|
</div>
|
|
<div class="shortcut-row">
|
|
<kbd class="shortcut-key">j</kbd>
|
|
<span class="shortcut-key-sep">/</span>
|
|
<kbd class="shortcut-key">k</kbd>
|
|
<span class="shortcut-desc">Move down / up</span>
|
|
</div>
|
|
<div class="shortcut-row">
|
|
<kbd class="shortcut-key">Enter</kbd>
|
|
<span class="shortcut-desc">Open selected item</span>
|
|
</div>
|
|
<div class="shortcut-row">
|
|
<kbd class="shortcut-key">e</kbd>
|
|
<span class="shortcut-desc">Edit current item (viewer)</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</template>
|
|
<template v-else>
|
|
<router-view />
|
|
</template>
|
|
<ToastNotification />
|
|
</template>
|
|
|
|
<style>
|
|
.skip-link {
|
|
position: absolute;
|
|
top: -100%;
|
|
left: 0.5rem;
|
|
z-index: 9999;
|
|
padding: 0.4rem 0.75rem;
|
|
background: var(--fs-accent);
|
|
color: var(--fs-text-on-action);
|
|
border-radius: 0 0 4px 4px;
|
|
font-size: 0.875rem;
|
|
text-decoration: none;
|
|
transition: top 0.1s;
|
|
}
|
|
.skip-link:focus {
|
|
top: 0;
|
|
}
|
|
|
|
.app-shell {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100vh;
|
|
height: 100dvh;
|
|
overflow: hidden;
|
|
}
|
|
.app-shell > .app-header {
|
|
flex-shrink: 0;
|
|
}
|
|
.app-content {
|
|
flex: 1;
|
|
min-height: 0;
|
|
overflow-y: auto;
|
|
}
|
|
.app-content:has(.knowledge-root) {
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* Version footer */
|
|
.app-footer {
|
|
flex-shrink: 0;
|
|
text-align: center;
|
|
padding: 0.2rem 0;
|
|
font-size: 0.68rem;
|
|
color: var(--fs-text-tertiary);
|
|
opacity: 0.45;
|
|
user-select: none;
|
|
letter-spacing: 0.03em;
|
|
}
|
|
|
|
/* Shortcuts overlay */
|
|
.shortcuts-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: var(--fs-overlay);
|
|
z-index: 9000;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.shortcuts-panel {
|
|
background: var(--fs-surface-raised);
|
|
border: 1px solid var(--fs-border-color);
|
|
border-radius: var(--fs-radius-lg);
|
|
box-shadow: 0 8px 32px var(--color-shadow);
|
|
width: min(420px, 92vw);
|
|
overflow: hidden;
|
|
}
|
|
.shortcuts-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0.85rem 1rem 0.75rem;
|
|
border-bottom: 1px solid var(--fs-border-color);
|
|
}
|
|
.shortcuts-header h3 {
|
|
margin: 0;
|
|
font-size: 1rem;
|
|
font-weight: 600;
|
|
color: var(--fs-text-primary);
|
|
}
|
|
.shortcuts-close {
|
|
background: none;
|
|
border: none;
|
|
font-size: 1.4rem;
|
|
line-height: 1;
|
|
color: var(--fs-text-tertiary);
|
|
cursor: pointer;
|
|
padding: 0 0.25rem;
|
|
}
|
|
.shortcuts-close:hover {
|
|
color: var(--fs-text-primary);
|
|
}
|
|
.shortcuts-body {
|
|
padding: 0.75rem 1rem 1rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
.shortcuts-section-title {
|
|
font-size: 0.72rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
color: var(--fs-text-tertiary);
|
|
margin-bottom: 0.4rem;
|
|
}
|
|
.shortcut-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.35rem;
|
|
padding: 0.2rem 0;
|
|
}
|
|
.shortcut-key {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-width: 1.8rem;
|
|
padding: 0.15rem 0.4rem;
|
|
background: var(--fs-surface-raised);
|
|
border: 1px solid var(--fs-border-color);
|
|
border-bottom-width: 2px;
|
|
border-radius: 4px;
|
|
font-size: 0.78rem;
|
|
font-family: ui-monospace, monospace;
|
|
color: var(--fs-text-primary);
|
|
white-space: nowrap;
|
|
user-select: none;
|
|
}
|
|
.shortcut-key-sep {
|
|
font-size: 0.78rem;
|
|
color: var(--fs-text-tertiary);
|
|
}
|
|
.shortcut-desc {
|
|
font-size: 0.875rem;
|
|
color: var(--fs-text-primary);
|
|
margin-left: 0.25rem;
|
|
}
|
|
|
|
/* Transition */
|
|
.shortcuts-fade-enter-active,
|
|
.shortcuts-fade-leave-active {
|
|
transition: opacity 0.15s ease;
|
|
}
|
|
.shortcuts-fade-enter-from,
|
|
.shortcuts-fade-leave-to {
|
|
opacity: 0;
|
|
}
|
|
</style>
|