Polish diagnostic reporting and local pseudonym follow-up

This commit is contained in:
vorotamoroz
2026-10-20 08:23:56 +00:00
parent c1ed71f686
commit 32874ea97f
21 changed files with 2425 additions and 154 deletions
@@ -10,7 +10,7 @@ import {
import { createBlob, escapeMarkdownValue, readAsBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
import { shouldBeIgnored } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
import { Menu, diff_match_patch, setIcon } from "@/deps.ts";
import { Menu, TFile, diff_match_patch, setIcon } from "@/deps.ts";
import { $msg } from "@/common/translation";
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
@@ -62,10 +62,70 @@ import {
executeMetadataIdentityRepair,
MetadataIdentityRepairExecutions,
} from "@/serviceFeatures/metadataIdentityRepair.ts";
import { showDiagnosticFileLookup } from "@/serviceFeatures/diagnosticFileLookup.ts";
export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void {
// const hatchWarn = this.createEl(paneEl, "div", { text: `To stop the boot up sequence for fixing problems on databases, you can put redflag.md on top of your vault (Rebooting obsidian is required).` });
// hatchWarn.addClass("op-warn-info");
void addPanel(paneEl, $msg("Setting.TroubleShooting")).then((paneEl) => {
void addPanel(paneEl, "Reporting Tools").then((paneEl) => {
new Setting(paneEl).setName($msg("Prepare the 'report' to create an issue")).addButton((button) =>
button
.setButtonText($msg("Copy Report to clipboard"))
.setCta()
.setDisabled(false)
.onClick(async () => {
await this.app.commands.executeCommandById("obsidian-livesync:dump-debug-info");
})
);
new Setting(paneEl)
.setName($msg("Copy database information for a file"))
.setDesc(
$msg(
"Copy revision, conflict, and local chunk availability information, including document and chunk identifiers but not file contents."
)
)
.addButton((button) =>
button.setButtonText($msg("Choose file")).onClick(async () => {
await chooseAndCopyFileDatabaseInfo(this.core);
})
);
new Setting(paneEl)
.setName($msg("Find a file from a report pseudonym"))
.setDesc(
$msg(
"Find matching paths in this Vault and local Metadata, then prepare a follow-up report using the same pseudonyms."
)
)
.addButton((button) =>
button.setButtonText($msg("Find file")).onClick(async () => {
await showDiagnosticFileLookup(this.core, async (path) => {
const file = this.app.vault.getAbstractFileByPath(path);
if (file instanceof TFile) await this.app.workspace.getLeaf(false).openFile(file);
});
})
);
new Setting(paneEl)
.setName($msg("Inspect database integrity"))
.setDesc(
$msg("Read normal-file Metadata and current conflicts locally and in CouchDB, then compare content for suspect groups. Choose original names or pseudonyms before starting.")
)
.addButton((button) =>
button.setButtonText($msg("Inspect")).onClick(() => eventHub.emitEvent(EVENT_INSPECT_DB_INTEGRITY))
);
new Setting(paneEl)
.setName($msg("Analyse database usage"))
.setDesc(
$msg(
"Analyse database usage and generate a TSV report for diagnosis yourself. You can paste the generated report with any spreadsheet you like."
)
)
.addButton((button) =>
button.setButtonText($msg("Analyse")).onClick(() => {
eventHub.emitEvent(EVENT_ANALYSE_DB_USAGE);
})
);
});
void addPanel(paneEl, "Checks and Recovery").then((paneEl) => {
new Setting(paneEl)
.setName($msg("Setting.TroubleShooting.Doctor"))
.setDesc($msg("Setting.TroubleShooting.Doctor.Desc"))
@@ -92,48 +152,9 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
eventHub.emitEvent(EVENT_REQUEST_RUN_FIX_INCOMPLETE);
})
);
});
new Setting(paneEl).setName($msg("Prepare the 'report' to create an issue")).addButton((button) =>
button
.setButtonText($msg("Copy Report to clipboard"))
.setCta()
.setDisabled(false)
.onClick(async () => {
await this.app.commands.executeCommandById("obsidian-livesync:dump-debug-info");
})
);
new Setting(paneEl)
.setName($msg("Copy database information for a file"))
.setDesc(
$msg(
"Copy revision, conflict, and local chunk availability information, including document and chunk identifiers but not file contents."
)
)
.addButton((button) =>
button.setButtonText($msg("Choose file")).onClick(async () => {
await chooseAndCopyFileDatabaseInfo(this.core);
})
);
new Setting(paneEl)
.setName($msg("Inspect database integrity"))
.setDesc(
$msg("Read normal-file Metadata and current conflicts locally and in CouchDB, then compare content for suspect groups. Choose original names or pseudonyms before starting.")
)
.addButton((button) =>
button.setButtonText($msg("Inspect")).onClick(() => eventHub.emitEvent(EVENT_INSPECT_DB_INTEGRITY))
);
new Setting(paneEl)
.setName($msg("Analyse database usage"))
.setDesc(
$msg(
"Analyse database usage and generate a TSV report for diagnosis yourself. You can paste the generated report with any spreadsheet you like."
)
)
.addButton((button) =>
button.setButtonText($msg("Analyse")).onClick(() => {
eventHub.emitEvent(EVENT_ANALYSE_DB_USAGE);
})
);
void addPanel(paneEl, $msg("Setting.TroubleShooting")).then((paneEl) => {
new Setting(paneEl)
.setName($msg("Reset notification threshold and check the remote database usage"))
.setDesc($msg("Reset the remote storage size threshold and check the remote storage size again."))
@@ -1,21 +1,31 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { onDestroy, onMount, tick } from "svelte";
import type { GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
import { $msg as translate } from "@/common/translation";
import { ProgressFragment } from "@vrtmrz/obsidian-plugin-kit/progress";
import type { DatabaseDiagnosticDialogOptions } from "@/serviceFeatures/databaseDiagnosticDialogTypes";
import type { DatabaseDiagnosticOutput } from "@/serviceFeatures/databaseDiagnostics";
import type {
DatabaseDiagnosticOutput,
DatabaseDiagnosticProgress,
} from "@/serviceFeatures/databaseDiagnostics";
import type { DiagnosticNameMode } from "@vrtmrz/livesync-commonlib/diagnostics";
const { setResult, getInitialData }: GuestDialogProps<"close", DatabaseDiagnosticDialogOptions> = $props();
let options = $state<DatabaseDiagnosticDialogOptions>();
let phase = $state<"choice" | "running" | "result" | "failed">("choice");
let nameMode = $state<DiagnosticNameMode>("pseudonym");
let processed = $state(0);
let includeRemote = $state(true);
let cancelling = $state(false);
let output = $state<DatabaseDiagnosticOutput>();
let selectedFormat = $state("");
let actionStatus = $state("");
let thisElement: HTMLDivElement;
let progressHost = $state<HTMLDivElement>();
let currentProgress: DatabaseDiagnosticProgress | undefined;
let progressFragment: ProgressFragment | undefined;
let lastProgressRender = 0;
let progressTimer: ReturnType<typeof setTimeout> | undefined;
let disposed = false;
const selected = $derived(output?.artifacts.find((artifact) => artifact.format === selectedFormat));
const title = $derived(
@@ -33,20 +43,44 @@
);
onMount(() => {
options = getInitialData?.();
const modalContainer = thisElement.closest(".modal-container");
const alreadyScoped = modalContainer?.classList.contains("sls-database-diagnostic-modal") ?? false;
modalContainer?.classList.add("sls-database-diagnostic-modal");
return () => {
if (!alreadyScoped) modalContainer?.classList.remove("sls-database-diagnostic-modal");
};
});
onDestroy(() => {
disposed = true;
clearTimeout(progressTimer);
options?.cancel();
});
async function begin() {
if (!options || phase !== "choice") return;
const diagnosticOptions = options;
if (!diagnosticOptions || phase !== "choice") return;
phase = "running";
await tick();
if (disposed) return;
currentProgress = undefined;
progressFragment = undefined;
progressHost?.replaceChildren();
updateProgress({
phase: diagnosticOptions.kind === "usage" ? "usage" : "metadata",
source: diagnosticOptions.kind === "integrity" ? "local" : undefined,
processed: 0,
elapsedMs: 0,
});
try {
const result = await options.run(nameMode, (count) => {
if (!disposed) processed = count;
});
const result = await diagnosticOptions.run(
nameMode,
(progress) => {
if (!disposed) updateProgress(progress);
},
includeRemote
);
if (disposed) return;
finishProgress(result.status);
output = result;
selectedFormat = result.artifacts[0].format;
phase = "result";
@@ -55,6 +89,58 @@
}
}
function finishProgress(status: DatabaseDiagnosticOutput["status"]) {
if (status === "cancelled") progressFragment?.cancel();
else progressFragment?.complete();
}
function updateProgress(progress: DatabaseDiagnosticProgress, force = false) {
const host = progressHost;
if (!host) return;
const stageChanged =
currentProgress?.phase !== progress.phase || currentProgress?.source !== progress.source;
const stageFinished =
progress.totalIsFinal !== false && progress.total !== undefined && progress.processed === progress.total;
currentProgress = progress;
const now = performance.now();
if (!force && !stageChanged && !stageFinished && now - lastProgressRender < 100) {
progressTimer ??= setTimeout(() => {
progressTimer = undefined;
if (!disposed && phase === "running" && currentProgress) updateProgress(currentProgress, true);
}, 100 - (now - lastProgressRender));
return;
}
clearTimeout(progressTimer);
progressTimer = undefined;
lastProgressRender = now;
const phaseTitle =
progress.phase === "usage" ? translate("Reading revisions") :
progress.phase === "accounting" ? translate("Calculating database usage") :
progress.phase === "metadata" ? "Metadata" : "Content";
const sourceTitle = progress.source ? ` · ${progress.source}` : "";
const unit = progress.phase === "usage" ? "revisions" :
progress.phase === "accounting" ? "items" : progress.phase === "metadata" ? "documents" : "groups";
const total = progress.total ?? 0;
const note = `${(progress.elapsedMs / 1000).toFixed(1)} s elapsed${
progress.totalIsFinal === false ? ` · ${translate("Discovering revisions")}` : ""
}`;
if (stageChanged) {
progressFragment?.complete();
progressFragment = new ProgressFragment({
value: progress.processed,
total,
title: `${phaseTitle}${sourceTitle}`,
note,
autoComplete: false,
document: host.ownerDocument,
formatNumeric: (snapshot) => `${snapshot.value} / ${currentProgress?.total === undefined ? "—" : snapshot.total} ${unit}`,
});
host.replaceChildren(progressFragment.fragment);
return;
}
progressFragment?.update({ value: progress.processed, total, title: `${phaseTitle}${sourceTitle}`, note });
}
async function copyReport(event: MouseEvent) {
if (!selected) return;
const owner = (event.currentTarget as HTMLElement).ownerDocument.defaultView;
@@ -82,13 +168,13 @@
</script>
<DialogHeader {title} />
<div class="database-diagnostic" data-diagnostic-phase={phase}>
<div class="database-diagnostic" bind:this={thisElement} data-diagnostic-phase={phase}>
{#if phase === "choice"}
<p>{translate("Choose how names appear in this run. Pseudonyms replace paths and identifiers consistently. Original names include private filenames. File contents and credentials are omitted in either mode.")}</p>
<p>{translate("Pseudonyms include a public salt, so guessed paths can be checked. Original names include private filenames. Reports omit file contents and credentials.")}</p>
<p>
{options?.kind === "usage"
? translate("Read available local revisions and report their Chunk references. Settings are left unchanged.")
: translate("Read normal-file Metadata, current conflicts, and content for suspect groups from this device and CouchDB. Other remote types and older revision history are outside this inspection. Results do not authorise deletion.")}
: translate("Inspect normal-file Metadata, current conflicts, and suspect content against the Vault. Remote inspection supports CouchDB. Older history is excluded; findings do not authorise deletion.")}
</p>
<fieldset class="diagnostic-options">
<legend>{translate("Names in this report")}</legend>
@@ -103,14 +189,21 @@
</label>
</div>
</fieldset>
{#if options?.kind === "integrity"}
<label class="diagnostic-remote-choice">
<input type="checkbox" bind:checked={includeRemote} />
<span>{translate("Include remote database")}</span>
</label>
{/if}
<div class="diagnostic-controls">
<button onclick={() => setResult("close")}>{translate("Cancel")}</button>
<button class="mod-cta" disabled={!options} onclick={() => void begin()}>{translate("Begin inspection")}</button>
</div>
{:else if phase === "running"}
<p role="status">
{cancelling ? translate("Cancelling inspection…") : `${translate("Items inspected")}: ${processed}`}
</p>
<div class="diagnostic-progress" role="status" aria-live="polite" bind:this={progressHost}></div>
{#if cancelling}
<p role="status">{translate("Cancelling inspection…")}</p>
{/if}
<button disabled={cancelling} onclick={() => { cancelling = true; options?.cancel(); }}>
{translate("Cancel inspection")}
</button>
@@ -149,16 +242,48 @@
</div>
<style>
.database-diagnostic { display: flex; flex-direction: column; gap: var(--size-4-2); }
p { margin-block: var(--size-4-2); }
:global(.sls-database-diagnostic-modal .modal) {
display: flex;
flex-direction: column;
}
:global(.sls-database-diagnostic-modal .modal-title) { flex: 0 0 auto; }
:global(.sls-database-diagnostic-modal .modal-content) {
display: flex;
flex-direction: column;
min-height: 0;
}
:global(.sls-database-diagnostic-modal .modal-content > .dialog-host) {
flex: 1 1 auto;
min-height: 0;
}
.database-diagnostic {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: var(--size-4-2);
min-height: 0;
}
.database-diagnostic > :not(textarea) { flex: 0 0 auto; }
p { margin: 0; }
.diagnostic-options { border: 0; margin: 0; padding: 0; min-width: 0; }
legend { padding: 0; margin-bottom: var(--size-4-2); }
.diagnostic-option-list { display: flex; flex-wrap: wrap; gap: var(--size-4-2) var(--size-4-4); }
.diagnostic-option-list label { display: flex; align-items: center; gap: var(--size-4-2); width: auto; min-height: 44px; cursor: pointer; }
.database-diagnostic .diagnostic-option-list label > span { width: auto; }
input[type="radio"] { margin: 0; flex: 0 0 auto; }
.diagnostic-controls { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--size-4-2); }
.diagnostic-remote-choice { display: flex; align-items: center; gap: var(--size-4-2); min-height: 44px; cursor: pointer; }
.database-diagnostic .diagnostic-remote-choice > span { width: auto; }
input[type="checkbox"] { margin: 0; flex: 0 0 auto; }
.diagnostic-controls { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-end; gap: var(--size-4-2); }
button { min-height: 44px; }
textarea { width: 100%; resize: vertical; font-family: var(--font-monospace); font-size: var(--font-ui-small); }
textarea {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
width: 100%;
resize: vertical;
font-family: var(--font-monospace);
font-size: var(--font-ui-small);
}
:global(body.is-mobile) textarea { max-height: 25vh; }
</style>
@@ -0,0 +1,367 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import type { GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
import { $msg as translate } from "@/common/translation";
import type {
DiagnosticFileCandidate,
DiagnosticFileLookupDialogOptions,
DiagnosticFileLookupResult,
} from "@/serviceFeatures/diagnosticFileLookupTypes";
import type { DatabaseDiagnosticOutput } from "@/serviceFeatures/databaseDiagnostics";
const { setResult, getInitialData }: GuestDialogProps<"close", DiagnosticFileLookupDialogOptions> = $props();
let options = $state<DiagnosticFileLookupDialogOptions>();
let phase = $state<"input" | "running" | "results" | "report">("input");
let token = $state("");
let completedToken = $state<string>();
let processed = $state(0);
let cancelling = $state(false);
let preparingReport = $state(false);
let lookupResult = $state<DiagnosticFileLookupResult>();
let output = $state<DatabaseDiagnosticOutput>();
let selectedFormat = $state("");
let actionStatus = $state("");
let thisElement: HTMLDivElement;
let disposed = false;
const title = translate("Find a file from a report pseudonym");
const selected = $derived(output?.artifacts.find((artifact) => artifact.format === selectedFormat));
const lookupStatus = $derived.by(() => {
if (phase === "running") {
return cancelling
? translate("Cancelling local search. Checked ${COUNT} entries.", { COUNT: `${processed}` })
: translate("Checked ${COUNT} local entries.", { COUNT: `${processed}` });
}
if (!lookupResult) return "";
if (!lookupResult.valid) return translate("Enter a valid report path pseudonym.");
if (lookupResult.status === "cancelled") return translate("Search cancelled. Matches found so far are shown.");
if (lookupResult.candidates.length === 0) {
return translate(
"No matches were found in current Vault paths or current local Metadata. Older history and remote-only files are not searched."
);
}
return translate("Found ${COUNT} local matches.", { COUNT: `${lookupResult.candidates.length}` });
});
onMount(() => {
options = getInitialData?.();
const modalContainer = thisElement.closest(".modal-container");
const alreadyScoped = modalContainer?.classList.contains("sls-diagnostic-file-lookup-modal") ?? false;
modalContainer?.classList.add("sls-diagnostic-file-lookup-modal");
return () => {
if (!alreadyScoped) modalContainer?.classList.remove("sls-diagnostic-file-lookup-modal");
};
});
onDestroy(() => {
disposed = true;
options?.cancel();
});
async function findFiles(event?: SubmitEvent) {
event?.preventDefault();
const dialogOptions = options;
if (!dialogOptions || phase === "running" || preparingReport) return;
phase = "running";
cancelling = false;
processed = 0;
lookupResult = undefined;
completedToken = undefined;
output = undefined;
actionStatus = "";
const searchedToken = token;
try {
const result = await dialogOptions.find(searchedToken, (count) => {
if (!disposed) processed = count;
});
if (disposed) return;
lookupResult = result;
completedToken = result.valid ? searchedToken : undefined;
phase = result.valid ? "results" : "input";
} catch {
if (!disposed) {
completedToken = undefined;
phase = "input";
actionStatus = translate("The local search could not finish.");
}
}
}
function updateToken(event: Event) {
token = (event.currentTarget as HTMLInputElement).value;
if (phase === "results") {
lookupResult = undefined;
completedToken = undefined;
phase = "input";
actionStatus = "";
}
}
function cancelSearch() {
cancelling = true;
options?.cancel();
}
async function openCandidate(candidate: DiagnosticFileCandidate) {
if (!options?.openFile || !candidate.storageExists) return;
try {
await options.openFile(candidate.path);
} catch {
actionStatus = translate("The file could not be opened.");
}
}
async function prepareReport(candidate: DiagnosticFileCandidate) {
const reportToken = completedToken;
if (!options || !reportToken || preparingReport || phase !== "results") return;
preparingReport = true;
actionStatus = "";
try {
const report = await options.prepareReport(candidate, reportToken);
if (disposed) return;
output = report;
selectedFormat = report.artifacts[0]?.format ?? "";
phase = "report";
} catch {
if (!disposed) actionStatus = translate("The follow-up report could not be prepared.");
} finally {
if (!disposed) preparingReport = false;
}
}
async function copyReport(event: MouseEvent) {
if (!selected) return;
const owner = (event.currentTarget as HTMLElement).ownerDocument.defaultView;
try {
await owner?.navigator.clipboard.writeText(selected.content);
actionStatus = translate("Report copied.");
} catch {
actionStatus = translate("Copy failed. Select the report text and copy it manually.");
}
}
function saveReport(event: MouseEvent) {
if (!selected) return;
const document = (event.currentTarget as HTMLElement).ownerDocument;
const url = URL.createObjectURL(new Blob([selected.content], { type: "text/plain;charset=utf-8" }));
const link = document.createElement("a");
link.href = url;
link.download = selected.filename;
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
actionStatus = translate("Download requested. You can also copy the report if downloads are unavailable on this device.");
}
</script>
<DialogHeader {title} />
<div class="diagnostic-file-lookup" bind:this={thisElement} data-diagnostic-phase={phase}>
{#if phase !== "report"}
{#if phase !== "results"}
<p>
{translate("Search current Vault paths and current local Metadata only. Older history and remote-only files are not searched.")}
</p>
<p>
{translate(
"The lookup does not read file contents or contact a remote database. Follow-up reports include current conflicts and merge bases."
)}
</p>
{/if}
<form class="lookup-form" onsubmit={findFiles}>
<label for="diagnostic-file-lookup-token">{translate("Report path pseudonym")}</label>
<input
id="diagnostic-file-lookup-token"
type="text"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
disabled={phase === "running" || preparingReport}
value={token}
oninput={updateToken}
/>
<div class="lookup-controls">
<button
type="submit"
class="mod-cta"
disabled={!options || phase === "running" || preparingReport}
>
{translate("Find file")}
</button>
{#if phase === "running"}
<button type="button" disabled={cancelling} onclick={cancelSearch}>{translate("Cancel")}</button>
{/if}
<button type="button" onclick={() => setResult("close")}>{translate("Close")}</button>
</div>
</form>
{:else if completedToken}
<p class="report-token"><span>{translate("Report path pseudonym")}</span>: <code>{completedToken}</code></p>
{/if}
{#if phase === "input" && actionStatus}
<p role="status">{actionStatus}</p>
{/if}
{#if phase === "running"}
<p role="status" aria-live="polite">{lookupStatus}</p>
{:else if phase === "input" && lookupResult?.valid === false}
<p role="status">{lookupStatus}</p>
{:else if phase === "results" && lookupResult?.valid}
<p role="status" aria-live="polite">{lookupStatus}</p>
{#if lookupResult.candidates.length > 0}
<ul class="lookup-candidates">
{#each lookupResult.candidates as candidate}
<li
data-diagnostic-candidate="true"
data-document-id={candidate.documentId}
data-source={candidate.source}
data-storage-exists={candidate.storageExists}
>
<div class="candidate-details">
<strong>{candidate.path}</strong>
<dl>
<dt>{translate("Source")}</dt>
<dd>{candidate.source === "metadata" ? translate("Metadata") : translate("Vault")}</dd>
<dt>{translate("Metadata document ID")}</dt>
<dd>{candidate.documentId ?? translate("No current Metadata document")}</dd>
<dt>{translate("Current Vault file")}</dt>
<dd>{candidate.storageExists ? translate("Present") : translate("Missing")}</dd>
</dl>
</div>
<div class="candidate-controls">
<button
type="button"
disabled={!options?.openFile || !candidate.storageExists || preparingReport}
onclick={() => void openCandidate(candidate)}
>
{translate("Open file")}
</button>
<button
type="button"
class="mod-cta"
disabled={preparingReport}
onclick={() => void prepareReport(candidate)}
>
{translate("Prepare follow-up report")}
</button>
</div>
</li>
{/each}
</ul>
{/if}
<p role="status">{preparingReport ? translate("Preparing follow-up report…") : actionStatus}</p>
{:else if phase === "report" && output}
<p role="status">{translate("This report uses pseudonyms. Review its coverage before sharing.")}</p>
<fieldset class="report-formats">
<legend>{translate("Report format")}</legend>
<div class="format-options">
{#each output.artifacts as artifact}
<label>
<input type="radio" name="diagnostic-file-report-format" value={artifact.format} bind:group={selectedFormat} />
<span>{artifact.format.toUpperCase()}</span>
</label>
{/each}
</div>
</fieldset>
<textarea readonly rows="16" aria-label={translate("Diagnostic report")} value={selected?.content ?? ""}></textarea>
<p role="status">{actionStatus}</p>
<div class="lookup-controls">
<button type="button" onclick={(event) => void copyReport(event)}>{translate("Copy report")}</button>
<button type="button" onclick={saveReport}>{translate("Save report")}</button>
<button type="button" onclick={() => { output = undefined; phase = "results"; }}>{translate("Back to matches")}</button>
<button type="button" onclick={() => setResult("close")}>{translate("Close")}</button>
</div>
{/if}
</div>
<style>
:global(.sls-diagnostic-file-lookup-modal .modal) {
display: flex;
flex-direction: column;
}
:global(.sls-diagnostic-file-lookup-modal .modal-title) { flex: 0 0 auto; }
:global(.sls-diagnostic-file-lookup-modal .modal-content) {
display: flex;
flex-direction: column;
min-height: 0;
}
:global(.sls-diagnostic-file-lookup-modal .modal-content > .dialog-host) {
flex: 1 1 auto;
min-height: 0;
}
.diagnostic-file-lookup {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: var(--size-4-2);
min-height: 0;
}
.diagnostic-file-lookup > p,
.diagnostic-file-lookup form,
.lookup-controls,
.candidate-controls,
.report-formats { flex: 0 0 auto; }
p { margin: 0; }
.lookup-form { display: flex; flex-direction: column; gap: var(--size-4-2); }
.lookup-form label { min-height: 24px; }
.lookup-form input { width: 100%; min-height: 44px; }
.lookup-controls,
.candidate-controls {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--size-4-2);
}
.diagnostic-file-lookup button { min-height: 44px; max-width: 100%; margin-left: 0; white-space: normal; }
.candidate-controls { min-width: 0; max-width: 100%; }
.lookup-candidates {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: var(--size-4-2);
min-height: 0;
margin: 0;
overflow: auto;
padding: 0;
list-style: none;
}
.lookup-candidates li {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
min-width: 0;
align-items: flex-start;
justify-content: space-between;
gap: var(--size-4-2);
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m);
padding: var(--size-4-2);
}
.candidate-details { flex: 1 1 18rem; min-width: 0; }
.candidate-details strong { display: block; overflow-wrap: anywhere; }
dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: var(--size-4-1) var(--size-4-2); margin: var(--size-4-2) 0 0; }
dt { font-weight: var(--font-semibold); }
dd { min-width: 0; margin: 0; overflow-wrap: anywhere; }
.report-formats { border: 0; margin: 0; padding: 0; min-width: 0; }
.report-token { overflow-wrap: anywhere; }
.report-token code { white-space: normal; }
legend { padding: 0; margin-bottom: var(--size-4-2); }
.format-options { display: flex; flex-wrap: wrap; gap: var(--size-4-2) var(--size-4-4); }
.format-options label { display: flex; align-items: center; gap: var(--size-4-2); width: auto; min-height: 44px; cursor: pointer; }
.diagnostic-file-lookup .format-options label > span { width: auto; }
input[type="radio"] { margin: 0; flex: 0 0 auto; }
textarea {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
width: 100%;
resize: vertical;
font-family: var(--font-monospace);
font-size: var(--font-ui-small);
}
:global(body.is-mobile) textarea { max-height: 25vh; }
@media (max-width: 480px) {
dl { grid-template-columns: minmax(0, 1fr); }
}
</style>