From a392ccab6a8d1e16de92dd1b6a30141828768ac7 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Tue, 19 May 2026 04:09:04 +0100 Subject: [PATCH] - Improved an error verbosity on concurrent processing on start-up process. - Now the `report` includes recent logs (of verbosity `verbose` even settings is not set to `verbose`). - Updating logs is now debounced to avoid excessive updates during rapid log generation. - Added a `Generate full report for opening the issue with debug info` command to the command palette, which generates a report without opening the settings dialogue. --- src/common/reportTool.ts | 142 ++++++++++++++++++ src/lib | 2 +- src/modules/features/ModuleLog.ts | 103 ++++++++++--- .../features/SettingDialogue/PaneHatch.ts | 131 +--------------- updates.md | 10 ++ 5 files changed, 240 insertions(+), 148 deletions(-) create mode 100644 src/common/reportTool.ts diff --git a/src/common/reportTool.ts b/src/common/reportTool.ts new file mode 100644 index 0000000..8eef539 --- /dev/null +++ b/src/common/reportTool.ts @@ -0,0 +1,142 @@ +import { REMOTE_COUCHDB, REMOTE_MINIO } from "@lib/common/models/setting.const"; +import type { ObsidianLiveSyncSettings } from "@lib/common/models/setting.type"; +import { generateCredentialObject } from "@lib/replication/httplib"; +import { parseHeaderValues } from "@lib/common/utils"; +import { requestToCouchDBWithCredentials } from "./utils"; +import { LOG_LEVEL_VERBOSE, Logger } from "@lib/common/logger"; +import { DEFAULT_SETTINGS } from "@lib/common/models/setting.const.defaults"; +import { isCloudantURI } from "@lib/pouchdb/utils_couchdb"; +import { compatGlobal } from "@lib/common/coreEnvFunctions"; +import { manifestVersion, packageVersion } from "@lib/common/coreEnvVars"; +import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore"; +function redactObject(obj: Record, dotted: string, redactedValue = "REDACTED") { + const keys = dotted.split("."); + let current = obj; + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + if (!(key in current)) { + current[key] = {} as Record; + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + current = current[key]; + } + const lastKey = keys[keys.length - 1]; + if (lastKey in current) { + current[lastKey] = redactedValue; + } + return obj; +} +export async function generateReport(settings: ObsidianLiveSyncSettings, core: LiveSyncBaseCore) { + let responseConfig: Record = {}; + const REDACTED = "𝑅𝐸𝐷𝐴𝐢𝑇𝐸𝐷"; + if (settings.remoteType == REMOTE_COUCHDB) { + try { + const credential = generateCredentialObject(settings); + const customHeaders = parseHeaderValues(settings.couchDB_CustomHeaders); + const r = await requestToCouchDBWithCredentials( + settings.couchDB_URI, + credential, + window.origin, + undefined, + undefined, + undefined, + customHeaders + ); + responseConfig = r.json as Record; + redactObject(responseConfig, "couch_httpd_auth.secret"); + redactObject(responseConfig, "couch_httpd_auth.authentication_db"); + redactObject(responseConfig, "couch_httpd_auth.authentication_redirect"); + redactObject(responseConfig, "couchdb.uuid"); + redactObject(responseConfig, "admins"); + redactObject(responseConfig, "users"); + redactObject(responseConfig, "chttpd_auth.secret"); + delete responseConfig["jwt_keys"]; + } catch (ex) { + Logger(ex, LOG_LEVEL_VERBOSE); + responseConfig = { + error: "Requesting information from the remote CouchDB has failed. If you are using IBM Cloudant, this is normal behaviour.", + }; + } + } else if (settings.remoteType == REMOTE_MINIO) { + responseConfig = { error: "Object Storage Synchronisation" }; + // + } + const defaultKeys = Object.keys(DEFAULT_SETTINGS) as (keyof ObsidianLiveSyncSettings)[]; + const pluginConfig = JSON.parse(JSON.stringify(settings)) as ObsidianLiveSyncSettings; + const pluginKeys = Object.keys(pluginConfig); + for (const key of pluginKeys) { + if (defaultKeys.includes(key as keyof ObsidianLiveSyncSettings)) continue; + delete pluginConfig[key as keyof ObsidianLiveSyncSettings]; + } + + pluginConfig.couchDB_DBNAME = REDACTED; + pluginConfig.couchDB_PASSWORD = REDACTED; + const scheme = pluginConfig.couchDB_URI.startsWith("http:") + ? "(HTTP)" + : pluginConfig.couchDB_URI.startsWith("https:") + ? "(HTTPS)" + : ""; + pluginConfig.couchDB_URI = isCloudantURI(pluginConfig.couchDB_URI) ? "cloudant" : `self-hosted${scheme}`; + pluginConfig.couchDB_USER = REDACTED; + pluginConfig.passphrase = REDACTED; + pluginConfig.encryptedPassphrase = REDACTED; + pluginConfig.encryptedCouchDBConnection = REDACTED; + pluginConfig.accessKey = REDACTED; + pluginConfig.secretKey = REDACTED; + const redact = (source: string) => `${REDACTED}(${source.length} letters)`; + const toSchemeOnly = (uri: string) => { + try { + return `${new URL(uri).protocol}//`; + } catch { + const matched = uri.match(/^[A-Za-z][A-Za-z0-9+.-]*:\/\//); + return matched?.[0] ?? REDACTED; + } + }; + pluginConfig.remoteConfigurations = Object.fromEntries( + Object.entries(pluginConfig.remoteConfigurations || {}).map(([id, config]) => [ + id, + { + ...config, + uri: toSchemeOnly(config.uri), + }, + ]) + ); + pluginConfig.region = redact(pluginConfig.region); + pluginConfig.bucket = redact(pluginConfig.bucket); + pluginConfig.pluginSyncExtendedSetting = {}; + pluginConfig.P2P_AppID = redact(pluginConfig.P2P_AppID); + pluginConfig.P2P_passphrase = redact(pluginConfig.P2P_passphrase); + pluginConfig.P2P_roomID = redact(pluginConfig.P2P_roomID); + pluginConfig.P2P_relays = redact(pluginConfig.P2P_relays); + pluginConfig.jwtKey = redact(pluginConfig.jwtKey); + pluginConfig.jwtSub = redact(pluginConfig.jwtSub); + pluginConfig.jwtKid = redact(pluginConfig.jwtKid); + pluginConfig.bucketCustomHeaders = redact(pluginConfig.bucketCustomHeaders); + pluginConfig.couchDB_CustomHeaders = redact(pluginConfig.couchDB_CustomHeaders); + pluginConfig.P2P_turnCredential = redact(pluginConfig.P2P_turnCredential); + pluginConfig.P2P_turnUsername = redact(pluginConfig.P2P_turnUsername); + pluginConfig.P2P_turnServers = `(${pluginConfig.P2P_turnServers.split(",").length} servers configured)`; + const endpoint = pluginConfig.endpoint; + if (endpoint == "") { + pluginConfig.endpoint = "Not configured or AWS"; + } else { + const endpointScheme = pluginConfig.endpoint.startsWith("http:") + ? "(HTTP)" + : pluginConfig.endpoint.startsWith("https:") + ? "(HTTPS)" + : ""; + pluginConfig.endpoint = `${endpoint.indexOf(".r2.cloudflarestorage.") !== -1 ? "R2" : "self-hosted?"}(${endpointScheme})`; + } + const obsidianInfo = { + navigator: compatGlobal.navigator.userAgent, + fileSystem: core.services.vault.isStorageInsensitive() ? "insensitive" : "sensitive", + }; + const result = { + obsidianInfo, + responseConfig, + pluginConfig, + manifestVersion, + packageVersion, + }; + return result; +} diff --git a/src/lib b/src/lib index a0af792..21230f9 160000 --- a/src/lib +++ b/src/lib @@ -1 +1 @@ -Subproject commit a0af792b48e6e7a5b14d7ee932b81796b65bd497 +Subproject commit 21230f972613cf7d154cdd93ac073a9f81f9f575 diff --git a/src/modules/features/ModuleLog.ts b/src/modules/features/ModuleLog.ts index 16de2d9..1eb7945 100644 --- a/src/modules/features/ModuleLog.ts +++ b/src/modules/features/ModuleLog.ts @@ -25,7 +25,7 @@ import { EVENT_ON_UNRESOLVED_ERROR, } from "../../common/events.ts"; import { AbstractObsidianModule } from "../AbstractObsidianModule.ts"; -import { addIcon, normalizePath, Notice } from "../../deps.ts"; +import { addIcon, debounce, normalizePath, Notice, stringifyYaml, type WorkspaceLeaf } from "../../deps.ts"; import { LOG_LEVEL_NOTICE, setGlobalLogFunction } from "octagonal-wheels/common/logger"; import { LogPaneView, VIEW_TYPE_LOG } from "./Log/LogPaneView.ts"; import { serialized } from "octagonal-wheels/concurrency/lock"; @@ -41,6 +41,8 @@ import { } from "@lib/string_and_binary/path.ts"; import { MARK_LOG_NETWORK_ERROR, MARK_LOG_SEPARATOR } from "@lib/services/lib/logUtils.ts"; import { NetworkWarningStyles } from "@lib/common/models/setting.const.ts"; +import { compatGlobal } from "@lib/common/coreEnvFunctions.ts"; +import { generateReport } from "@/common/reportTool.ts"; // This module cannot be a core module because it depends on the Obsidian UI. @@ -50,18 +52,51 @@ const globalLogFunction = (message: any, level?: number, key?: string) => { const messageX = message instanceof Error ? new LiveSyncError("[Error Logged]: " + message.message, { cause: message }) - : message; + : typeof message === "string" + ? message + : JSON.stringify(message); const entry = { message: messageX, level, key } as LogEntry; recentLogEntries.value = [...recentLogEntries.value, entry]; }; setGlobalLogFunction(globalLogFunction); -let recentLogs = [] as string[]; +// Keep the recent logs in memory for display, but also keep a longer history in logForDump for when the user wants to see more logs. +// logForDump is not reactive and is only used for dumping logs when requested, while recentLogs is reactive and is used for displaying logs in the UI. +const logForDump = [] as string[]; function addLog(log: string) { - recentLogs = [...recentLogs, log].splice(-200); - logMessages.value = recentLogs; + logForDump.push(log); + while (logForDump.length > 1000) { + logForDump.shift(); + } } + +// Display log is kept separate from the full log history to optimize performance and memory usage. +// And debounce the updates to the display log to avoid excessive UI updates when there are many log entries in a short time. +const logForDisplay = [] as string[]; + +const updateLogMessage = debounce(() => { + logMessages.value = [...logForDisplay]; +}, 25); +function addDisplayLog(log: string) { + logForDisplay.push(log); + while (logForDisplay.length > 200) { + logForDisplay.shift(); + } + updateLogMessage(); +} + +const redactPatterns = [/PBKDF2 salt \(Security Seed\):.*$/]; +function redactLog(log: string) { + let redactedLog = log; + for (const pattern of redactPatterns) { + redactedLog = redactedLog.replace(pattern, (match) => { + return match.split(":")[0] + ": [REDACTED]"; + }); + } + return redactedLog; +} + // logStore.intercept(e => e.slice(Math.min(e.length - 200, 0))); const showDebugLog = false; @@ -86,15 +121,15 @@ export class ModuleLog extends AbstractObsidianModule { // const emptyMark = `\u{2003}`; function padLeftSpComputed(numI: ReactiveValue, mark: string) { const formatted = reactiveSource(""); - let timer: ReturnType | undefined = undefined; + let timer: number | undefined = undefined; let maxLen = 1; numI.onChanged((numX) => { const num = numX.value; const numLen = `${Math.abs(num)}`.length + 1; maxLen = maxLen < numLen ? numLen : maxLen; - if (timer) clearTimeout(timer); + if (timer) compatGlobal.clearTimeout(timer); if (num == 0) { - timer = setTimeout(() => { + timer = compatGlobal.setTimeout(() => { formatted.value = ""; maxLen = 1; }, 3000); @@ -323,7 +358,7 @@ export class ModuleLog extends AbstractObsidianModule { if (this.nextFrameQueue) { return; } - this.nextFrameQueue = requestAnimationFrame(() => { + this.nextFrameQueue = compatGlobal.requestAnimationFrame(() => { this.nextFrameQueue = undefined; const { message, status } = this.statusBarLabels.value; // const recent = logMessages.value; @@ -346,7 +381,8 @@ export class ModuleLog extends AbstractObsidianModule { (a, b) => (a < b.ttl ? a : b.ttl), Number.MAX_SAFE_INTEGER ); - if (this.logLines.length > 0) setTimeout(() => this.applyStatusBarText(), minimumNext - now); + if (this.logLines.length > 0) + compatGlobal.setTimeout(() => this.applyStatusBarText(), minimumNext - now); const recent = this.logLines.map((e) => e.message); const recentLogs = recent.reverse().join("\n"); if (isDirty("recentLogs", recentLogs)) this.logHistory!.innerText = recentLogs; @@ -368,7 +404,7 @@ export class ModuleLog extends AbstractObsidianModule { if (this.statusDiv) { this.statusDiv.remove(); } - document.querySelectorAll(`.livesync-status`)?.forEach((e) => e.remove()); + compatGlobal.document.querySelectorAll(`.livesync-status`)?.forEach((e) => e.remove()); return Promise.resolve(true); } _everyOnloadStart(): Promise { @@ -390,7 +426,28 @@ export class ModuleLog extends AbstractObsidianModule { void this.services.API.showWindow(VIEW_TYPE_LOG); }, }); - this.registerView(VIEW_TYPE_LOG, (leaf) => new LogPaneView(leaf, this.plugin)); + this.addCommand({ + id: "dump-debug-info", + name: "Generate full report for opening the issue with debug info", + callback: async () => { + const recentLog = [...logForDump]; + const report = await generateReport(this.services.setting.currentSettings(), this.core); + const info = { + ...report, + recentLog: recentLog.map(redactLog), + }; + const yaml = `\`\`\`\` +# ---- Debug Info Dump ---- +${stringifyYaml(info)} +\`\`\`\``; + if (await this.services.UI.promptCopyToClipboard("Debug info", yaml)) { + new Notice( + "Debug info copied to clipboard. You can paste it in the issue. Be careful as it may contain sensitive information, review it before sharing." + ); + } + }, + }); + this.registerView(VIEW_TYPE_LOG, (leaf: WorkspaceLeaf) => new LogPaneView(leaf, this.plugin)); return Promise.resolve(true); } private _everyOnloadAfterLoadSettings(): Promise { @@ -404,7 +461,7 @@ export class ModuleLog extends AbstractObsidianModule { void this.setFileStatus(); }); - const w = document.querySelectorAll(`.livesync-status`); + const w = compatGlobal.document.querySelectorAll(`.livesync-status`); w.forEach((e) => e.remove()); this.observeForLogs(); @@ -421,6 +478,8 @@ export class ModuleLog extends AbstractObsidianModule { this.statusBar?.addClass("syncstatusbar"); } this.adjustStatusDivPosition(); + this._log("Log module loaded", LOG_LEVEL_INFO); + this._log("Verbose log", LOG_LEVEL_VERBOSE); return Promise.resolve(true); } @@ -444,11 +503,12 @@ export class ModuleLog extends AbstractObsidianModule { if (level == LOG_LEVEL_DEBUG && !showDebugLog) { return; } + let memoOnly = false; if (level <= LOG_LEVEL_INFO && this.settings && this.settings.lessInformationInLog) { - return; + memoOnly = true; } if (this.settings && !this.settings.showVerboseLog && level == LOG_LEVEL_VERBOSE) { - return; + memoOnly = true; } const vaultName = this.services.vault.getVaultName(); const now = new Date(); @@ -469,6 +529,15 @@ export class ModuleLog extends AbstractObsidianModule { ? `${errorInfo}` : JSON.stringify(message, null, 2); const newMessage = timestamp + "->" + messageContent; + + if (this.settings?.writeLogToTheFile) { + this.writeLogToTheFile(now, vaultName, newMessage); + } + addLog(newMessage); + if (memoOnly) { + return; + } + addDisplayLog(newMessage); if (message instanceof Error) { console.error(vaultName + ":" + newMessage); } else if (level >= LOG_LEVEL_INFO) { @@ -479,10 +548,6 @@ export class ModuleLog extends AbstractObsidianModule { if (!this.settings?.showOnlyIconsOnEditor) { this.statusLog.value = messageContent; } - if (this.settings?.writeLogToTheFile) { - this.writeLogToTheFile(now, vaultName, newMessage); - } - addLog(newMessage); this.logLines.push({ ttl: now.getTime() + 3000, message: newMessage }); if (level >= LOG_LEVEL_NOTICE) { diff --git a/src/modules/features/SettingDialogue/PaneHatch.ts b/src/modules/features/SettingDialogue/PaneHatch.ts index ebfb377..d9ca27c 100644 --- a/src/modules/features/SettingDialogue/PaneHatch.ts +++ b/src/modules/features/SettingDialogue/PaneHatch.ts @@ -39,6 +39,7 @@ import { EVENT_REQUEST_SHOW_HISTORY } from "../../../common/obsidianEvents.ts"; import { generateCredentialObject } from "../../../lib/src/replication/httplib.ts"; import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; import type { PageFunctions } from "./SettingPane.ts"; +import { generateReport } from "@/common/reportTool.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"); @@ -69,140 +70,14 @@ 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 () => { - let responseConfig: any = {}; - const REDACTED = "𝑅𝐸𝐷𝐴𝐢𝑇𝐸𝐷"; - if (this.editingSettings.remoteType == REMOTE_COUCHDB) { - try { - const credential = generateCredentialObject(this.editingSettings); - const customHeaders = parseHeaderValues(this.editingSettings.couchDB_CustomHeaders); - const r = await requestToCouchDBWithCredentials( - this.editingSettings.couchDB_URI, - credential, - window.origin, - undefined, - undefined, - undefined, - customHeaders - ); - - Logger(JSON.stringify(r.json, null, 2)); - - responseConfig = r.json; - responseConfig["couch_httpd_auth"].secret = REDACTED; - responseConfig["couch_httpd_auth"].authentication_db = REDACTED; - responseConfig["couch_httpd_auth"].authentication_redirect = REDACTED; - responseConfig["couchdb"].uuid = REDACTED; - responseConfig["admins"] = REDACTED; - delete responseConfig["jwt_keys"]; - if ("secret" in responseConfig["chttpd_auth"]) - responseConfig["chttpd_auth"].secret = REDACTED; - } catch (ex) { - Logger(ex, LOG_LEVEL_VERBOSE); - responseConfig = { - error: "Requesting information from the remote CouchDB has failed. If you are using IBM Cloudant, this is normal behaviour.", - }; - } - } else if (this.editingSettings.remoteType == REMOTE_MINIO) { - responseConfig = { error: "Object Storage Synchronisation" }; - // - } - const defaultKeys = Object.keys(DEFAULT_SETTINGS) as (keyof ObsidianLiveSyncSettings)[]; - const pluginConfig = JSON.parse(JSON.stringify(this.editingSettings)) as ObsidianLiveSyncSettings; - const pluginKeys = Object.keys(pluginConfig); - for (const key of pluginKeys) { - if (defaultKeys.includes(key as any)) continue; - delete pluginConfig[key as keyof ObsidianLiveSyncSettings]; - } - - pluginConfig.couchDB_DBNAME = REDACTED; - pluginConfig.couchDB_PASSWORD = REDACTED; - const scheme = pluginConfig.couchDB_URI.startsWith("http:") - ? "(HTTP)" - : pluginConfig.couchDB_URI.startsWith("https:") - ? "(HTTPS)" - : ""; - pluginConfig.couchDB_URI = isCloudantURI(pluginConfig.couchDB_URI) - ? "cloudant" - : `self-hosted${scheme}`; - pluginConfig.couchDB_USER = REDACTED; - pluginConfig.passphrase = REDACTED; - pluginConfig.encryptedPassphrase = REDACTED; - pluginConfig.encryptedCouchDBConnection = REDACTED; - pluginConfig.accessKey = REDACTED; - pluginConfig.secretKey = REDACTED; - const redact = (source: string) => `${REDACTED}(${source.length} letters)`; - const toSchemeOnly = (uri: string) => { - try { - return `${new URL(uri).protocol}//`; - } catch { - const matched = uri.match(/^[A-Za-z][A-Za-z0-9+.-]*:\/\//); - return matched?.[0] ?? REDACTED; - } - }; - pluginConfig.remoteConfigurations = Object.fromEntries( - Object.entries(pluginConfig.remoteConfigurations || {}).map(([id, config]) => [ - id, - { - ...config, - uri: toSchemeOnly(config.uri), - }, - ]) - ); - pluginConfig.region = redact(pluginConfig.region); - pluginConfig.bucket = redact(pluginConfig.bucket); - pluginConfig.pluginSyncExtendedSetting = {}; - pluginConfig.P2P_AppID = redact(pluginConfig.P2P_AppID); - pluginConfig.P2P_passphrase = redact(pluginConfig.P2P_passphrase); - pluginConfig.P2P_roomID = redact(pluginConfig.P2P_roomID); - pluginConfig.P2P_relays = redact(pluginConfig.P2P_relays); - pluginConfig.jwtKey = redact(pluginConfig.jwtKey); - pluginConfig.jwtSub = redact(pluginConfig.jwtSub); - pluginConfig.jwtKid = redact(pluginConfig.jwtKid); - pluginConfig.bucketCustomHeaders = redact(pluginConfig.bucketCustomHeaders); - pluginConfig.couchDB_CustomHeaders = redact(pluginConfig.couchDB_CustomHeaders); - pluginConfig.P2P_turnCredential = redact(pluginConfig.P2P_turnCredential); - pluginConfig.P2P_turnUsername = redact(pluginConfig.P2P_turnUsername); - pluginConfig.P2P_turnServers = `(${pluginConfig.P2P_turnServers.split(",").length} servers configured)`; - const endpoint = pluginConfig.endpoint; - if (endpoint == "") { - pluginConfig.endpoint = "Not configured or AWS"; - } else { - const endpointScheme = pluginConfig.endpoint.startsWith("http:") - ? "(HTTP)" - : pluginConfig.endpoint.startsWith("https:") - ? "(HTTPS)" - : ""; - pluginConfig.endpoint = `${endpoint.indexOf(".r2.cloudflarestorage.") !== -1 ? "R2" : "self-hosted?"}(${endpointScheme})`; - } - const obsidianInfo = { - navigator: navigator.userAgent, - fileSystem: this.core.services.vault.isStorageInsensitive() ? "insensitive" : "sensitive", - }; - const msgConfig = `# ---- Obsidian info ---- -${stringifyYaml(obsidianInfo)} ---- -# ---- remote config ---- -${stringifyYaml(responseConfig)} ---- -# ---- Plug-in config ---- -${stringifyYaml({ - version: this.manifestVersion, - ...pluginConfig, -})}`; - console.log(msgConfig); - if ((await this.services.UI.promptCopyToClipboard("Generated report", msgConfig)) == true) { - // await navigator.clipboard.writeText(msgConfig); - // Logger( - // `Generated report has been copied to clipboard. Please report the issue with this! Thank you for your cooperation!`, - // LOG_LEVEL_NOTICE - // ); - } + await this.app.commands.executeCommandById("obsidian-livesync:dump-debug-info"); }) ); new Setting(paneEl) diff --git a/updates.md b/updates.md index 8a4e9e6..1ceefdc 100644 --- a/updates.md +++ b/updates.md @@ -3,6 +3,16 @@ Since 19th July, 2025 (beta1 in 0.25.0-beta1, 13th July, 2025) The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md). Because 0.25 got a lot of updates, thankfully, compatibility is kept and we do not need breaking changes! In other words, when get enough stabled. The next version will be v1.0.0. Even though it my hope. +## Unreleased + +### Improved + +- Improved an error verbosity on concurrent processing on start-up process. +- Now the `report` includes recent logs (of verbosity `verbose` even settings is not set to `verbose`). +- Updating logs is now debounced to avoid excessive updates during rapid log generation. +- Added a `Generate full report for opening the issue with debug info` command to the command palette, which generates a report without opening the settings dialogue. + + ## 0.25.64 17th May, 2026