From 56939706a5f838ba7c8e2f5dcb1b7df5feed3000 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Fri, 8 May 2026 11:12:44 +0000 Subject: [PATCH 1/4] fix(web): preserve local terminal image previews --- .../issue-250/terminal-local-image-proof.png | Bin 0 -> 36392 bytes .../src/services/terminal-image-fetch-core.ts | 86 ++++++++- .../tests/terminal-image-fetch-core.test.ts | 43 ++++- packages/app/src/web/terminal-image-paths.ts | 3 +- .../app/src/web/terminal-inline-images.ts | 174 +++++++++++++++--- .../src/web/terminal-panel-runtime-core.ts | 110 ++++++++++- .../src/web/terminal-panel-runtime-types.ts | 1 + .../docker-git/terminal-image-paths.test.ts | 12 ++ .../terminal-inline-images-core.test.ts | 75 +++++++- 9 files changed, 466 insertions(+), 38 deletions(-) create mode 100644 docs/pr-screenshots/issue-250/terminal-local-image-proof.png diff --git a/docs/pr-screenshots/issue-250/terminal-local-image-proof.png b/docs/pr-screenshots/issue-250/terminal-local-image-proof.png new file mode 100644 index 0000000000000000000000000000000000000000..8c8828f8c5780613924a93d471042d923cd7060e GIT binary patch literal 36392 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 U--A~EY;CHFnlDS_Uw{0+0QhvZuK)l5 literal 0 HcmV?d00001 diff --git a/packages/api/src/services/terminal-image-fetch-core.ts b/packages/api/src/services/terminal-image-fetch-core.ts index 937ac99d..5eb53c6c 100644 --- a/packages/api/src/services/terminal-image-fetch-core.ts +++ b/packages/api/src/services/terminal-image-fetch-core.ts @@ -1,3 +1,5 @@ +import { fileURLToPath } from "node:url" + export type TerminalImageFetchPlan = | { readonly _tag: "InvalidTerminalImageFetch" @@ -23,6 +25,21 @@ const controlCharRange = `${String.fromCodePoint(0)}-${String.fromCodePoint(0x1F const deleteChar = String.fromCodePoint(0x7F) const invalidCharacterPattern = new RegExp(String.raw`[\s${controlCharRange}${deleteChar}]`, "u") const traversalPattern = /(?:^|\/)(?:\.|\.\.)(?=\/|$)/u +const urlSchemePattern = /^[A-Za-z][A-Za-z0-9+.-]*:/u +const fileUrlPattern = /^file:\/\//iu +const encodedPathSeparatorPattern = /%(?:2f|5c)/iu +const fileUrlBackslashPattern = /\\/u +const fileUrlTraversalPattern = /(?:^|[\\/])(?:\.|%2e)(?:(?:\.|%2e))?(?=[\\/]|$)/iu + +type TerminalImagePathNormalization = + | { + readonly _tag: "InvalidTerminalImagePath" + readonly message: string + } + | { + readonly _tag: "ValidTerminalImagePath" + readonly path: string + } const lowercaseExtension = (path: string): string | null => { const lastDot = path.lastIndexOf(".") @@ -32,20 +49,79 @@ const lowercaseExtension = (path: string): string | null => { return path.slice(lastDot + 1).toLowerCase() } +const rawFileUrlPathname = (path: string): string => { + const withoutScheme = path.slice("file://".length) + const pathStart = withoutScheme.indexOf("/") + if (pathStart < 0) { + return "" + } + const pathAndSuffix = withoutScheme.slice(pathStart) + const queryStart = pathAndSuffix.indexOf("?") + const hashStart = pathAndSuffix.indexOf("#") + if (queryStart < 0 && hashStart < 0) { + return pathAndSuffix + } + if (queryStart < 0) { + return pathAndSuffix.slice(0, hashStart) + } + if (hashStart < 0) { + return pathAndSuffix.slice(0, queryStart) + } + return pathAndSuffix.slice(0, Math.min(queryStart, hashStart)) +} + +const normalizeTerminalImagePath = (path: string): TerminalImagePathNormalization => { + if (!urlSchemePattern.test(path)) { + return { _tag: "ValidTerminalImagePath", path } + } + if (!fileUrlPattern.test(path)) { + return { _tag: "InvalidTerminalImagePath", message: "Only file:// image URLs are supported." } + } + + const rawPathname = rawFileUrlPathname(path) + if (fileUrlTraversalPattern.test(rawPathname)) { + return { _tag: "InvalidTerminalImagePath", message: "Image path must not contain '.' or '..' segments." } + } + if (encodedPathSeparatorPattern.test(rawPathname) || fileUrlBackslashPattern.test(rawPathname)) { + return { + _tag: "InvalidTerminalImagePath", + message: "Image file URL must not contain encoded or backslash path separators." + } + } + + try { + const url = new URL(path) + if (url.protocol !== "file:" || (url.hostname !== "" && url.hostname !== "localhost")) { + return { _tag: "InvalidTerminalImagePath", message: "Image file URL must point to a local path." } + } + if (url.search.length > 0 || url.hash.length > 0) { + return { _tag: "InvalidTerminalImagePath", message: "Image file URL must not include query or fragment." } + } + return { _tag: "ValidTerminalImagePath", path: fileURLToPath(url, { windows: false }) } + } catch { + return { _tag: "InvalidTerminalImagePath", message: "Image file URL is invalid." } + } +} + export const planTerminalImageFetch = (path: string): TerminalImageFetchPlan => { if (typeof path !== "string" || path.length === 0) { return { _tag: "InvalidTerminalImageFetch", message: "Image path is required." } } - if (!path.startsWith("/")) { + const normalized = normalizeTerminalImagePath(path) + if (normalized._tag === "InvalidTerminalImagePath") { + return { _tag: "InvalidTerminalImageFetch", message: normalized.message } + } + const containerPath = normalized.path + if (!containerPath.startsWith("/")) { return { _tag: "InvalidTerminalImageFetch", message: "Image path must be absolute." } } - if (invalidCharacterPattern.test(path)) { + if (invalidCharacterPattern.test(containerPath)) { return { _tag: "InvalidTerminalImageFetch", message: "Image path contains invalid characters." } } - if (traversalPattern.test(path)) { + if (traversalPattern.test(containerPath)) { return { _tag: "InvalidTerminalImageFetch", message: "Image path must not contain '.' or '..' segments." } } - const extension = lowercaseExtension(path) + const extension = lowercaseExtension(containerPath) if (extension === null) { return { _tag: "InvalidTerminalImageFetch", message: "Image path must include a file extension." } } @@ -53,5 +129,5 @@ export const planTerminalImageFetch = (path: string): TerminalImageFetchPlan => if (mediaType === undefined) { return { _tag: "InvalidTerminalImageFetch", message: `Unsupported image extension: .${extension}` } } - return { _tag: "ValidTerminalImageFetch", containerPath: path, mediaType } + return { _tag: "ValidTerminalImageFetch", containerPath, mediaType } } diff --git a/packages/api/tests/terminal-image-fetch-core.test.ts b/packages/api/tests/terminal-image-fetch-core.test.ts index 635871e8..a9f38b39 100644 --- a/packages/api/tests/terminal-image-fetch-core.test.ts +++ b/packages/api/tests/terminal-image-fetch-core.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "@effect/vitest" import { planTerminalImageFetch } from "../src/services/terminal-image-fetch-core.js" describe("terminal image fetch core", () => { - it("accepts an absolute path with a supported image extension", () => { + it("continues to accept an absolute path with a supported image extension", () => { expect(planTerminalImageFetch("/tmp/issue232-main.png")).toEqual({ _tag: "ValidTerminalImageFetch", containerPath: "/tmp/issue232-main.png", @@ -11,6 +11,14 @@ describe("terminal image fetch core", () => { }) }) + it("accepts a file URL and normalizes it to an absolute container path", () => { + expect(planTerminalImageFetch("file:///tmp/phantom-e2e.tuhl98/wallet-step-after-password.png")).toEqual({ + _tag: "ValidTerminalImageFetch", + containerPath: "/tmp/phantom-e2e.tuhl98/wallet-step-after-password.png", + mediaType: "image/png" + }) + }) + it("maps each supported extension to its media type", () => { expect(planTerminalImageFetch("/a.jpg")).toMatchObject({ mediaType: "image/jpeg" }) expect(planTerminalImageFetch("/a.jpeg")).toMatchObject({ mediaType: "image/jpeg" }) @@ -33,6 +41,13 @@ describe("terminal image fetch core", () => { }) }) + it("rejects non-file URLs", () => { + expect(planTerminalImageFetch("https://example.com/tmp/photo.png")).toEqual({ + _tag: "InvalidTerminalImageFetch", + message: "Only file:// image URLs are supported." + }) + }) + it("rejects whitespace and control characters", () => { expect(planTerminalImageFetch("/tmp/has space.png")).toMatchObject({ _tag: "InvalidTerminalImageFetch" @@ -51,6 +66,32 @@ describe("terminal image fetch core", () => { }) }) + it("rejects traversal segments in file URLs before URL normalization", () => { + expect(planTerminalImageFetch("file:///tmp/../etc/photo.png")).toMatchObject({ + _tag: "InvalidTerminalImageFetch", + message: "Image path must not contain '.' or '..' segments." + }) + expect(planTerminalImageFetch("file:///tmp/%2E%2E/etc/photo.png")).toMatchObject({ + _tag: "InvalidTerminalImageFetch", + message: "Image path must not contain '.' or '..' segments." + }) + }) + + it("rejects unsafe file URL forms", () => { + expect(planTerminalImageFetch("file://example.com/tmp/photo.png")).toMatchObject({ + _tag: "InvalidTerminalImageFetch", + message: "Image file URL must point to a local path." + }) + expect(planTerminalImageFetch("file:///tmp/photo.png?download=1")).toMatchObject({ + _tag: "InvalidTerminalImageFetch", + message: "Image file URL must not include query or fragment." + }) + expect(planTerminalImageFetch("file:///tmp/%2Fetc/photo.png")).toMatchObject({ + _tag: "InvalidTerminalImageFetch", + message: "Image file URL must not contain encoded or backslash path separators." + }) + }) + it("rejects unsupported extensions", () => { expect(planTerminalImageFetch("/tmp/file.bmp")).toMatchObject({ _tag: "InvalidTerminalImageFetch" diff --git a/packages/app/src/web/terminal-image-paths.ts b/packages/app/src/web/terminal-image-paths.ts index 18773a6c..903ddf23 100644 --- a/packages/app/src/web/terminal-image-paths.ts +++ b/packages/app/src/web/terminal-image-paths.ts @@ -2,8 +2,9 @@ const supportedExtensions: ReadonlyArray = ["png", "jpg", "jpeg", "gif", const extensionAlternation = supportedExtensions.join("|") +const absoluteImagePathSource = String.raw`/[^\s"'(<>\[\]{}|\\]+\.(?:${extensionAlternation})` const imagePathPattern = new RegExp( - String.raw`(?:^|[\s"'(<>\[\]{}|])(/[^\s"'(<>\[\]{}|\\]+\.(?:${extensionAlternation}))(?=$|[\s"')<>\[\]{}|.,;:?!])`, + String.raw`(?:^|[\s"'(<>\[\]{}|])((?:file://)?${absoluteImagePathSource})(?=$|[\s"')<>\[\]{}|.,;:?!])`, "giu" ) diff --git a/packages/app/src/web/terminal-inline-images.ts b/packages/app/src/web/terminal-inline-images.ts index 1d741cdb..8581de9d 100644 --- a/packages/app/src/web/terminal-inline-images.ts +++ b/packages/app/src/web/terminal-inline-images.ts @@ -14,43 +14,105 @@ const terminalInlineImagePreviewColumns = 16 const terminalInlineImagePreviewHeightPx = 56 const terminalInlineImagePreviewWidthPx = 96 -type TerminalInlineImageEntry = { - readonly fetchUrl: string - readonly path: string +export type TerminalInlineImageEntry = + | { + readonly _tag: "AvailableTerminalInlineImage" + readonly displayUrl: string + readonly fetchUrl: string + readonly path: string + } + | { + readonly _tag: "UnavailableTerminalInlineImage" + readonly fetchUrl: string + readonly path: string + } + +type TerminalInlineImageObjectUrlCache = Map + +const availableTerminalInlineImageEntry = ( + path: string, + fetchUrl: string, + displayUrl: string +): TerminalInlineImageEntry => ({ + _tag: "AvailableTerminalInlineImage", + displayUrl, + fetchUrl, + path +}) + +export const unavailableTerminalInlineImageEntry = ( + path: string, + fetchUrl: string +): TerminalInlineImageEntry => ({ + _tag: "UnavailableTerminalInlineImage", + fetchUrl, + path +}) + +export const cachedTerminalInlineImageEntry = ( + cache: TerminalInlineImageObjectUrlCache, + path: string, + fetchUrl: string +): TerminalInlineImageEntry | null => { + const displayUrl = cache.get(path) + return displayUrl === undefined ? null : availableTerminalInlineImageEntry(path, fetchUrl, displayUrl) } -const openImage = (fetchUrl: string): void => { - const imageWindow = window.open(fetchUrl, "_blank", "noopener,noreferrer") - if (imageWindow === null) { - return - } - imageWindow.opener = null +const revokeTerminalInlineImageObjectUrl = (displayUrl: string): void => { + URL.revokeObjectURL(displayUrl) } -const appendDecorationDisposable = ( - lifecycle: TerminalLifecycleState, - disposable: IDisposable +const trimTerminalInlineImageObjectUrlCache = ( + cache: TerminalInlineImageObjectUrlCache ): void => { - lifecycle.inlineImageDisposables.push(disposable) - if (lifecycle.inlineImageDisposables.length <= terminalInlineImagePreviewLimit) { - return + while (cache.size > terminalInlineImagePreviewLimit) { + const first = cache.entries().next() + if (first.done) { + return + } + const [path, displayUrl] = first.value + cache.delete(path) + revokeTerminalInlineImageObjectUrl(displayUrl) } - lifecycle.inlineImageDisposables.shift()?.dispose() } -const renderInlineImageElement = ( - element: HTMLElement, - entry: TerminalInlineImageEntry +export const cacheTerminalInlineImageBlob = ( + cache: TerminalInlineImageObjectUrlCache, + path: string, + fetchUrl: string, + blob: Blob +): TerminalInlineImageEntry => { + const cached = cachedTerminalInlineImageEntry(cache, path, fetchUrl) + if (cached !== null) { + return cached + } + const displayUrl = URL.createObjectURL(blob) + cache.set(path, displayUrl) + trimTerminalInlineImageObjectUrlCache(cache) + return availableTerminalInlineImageEntry(path, fetchUrl, displayUrl) +} + +export const revokeTerminalInlineImageObjectUrlCache = ( + cache: TerminalInlineImageObjectUrlCache ): void => { - if (element.dataset["path"] === entry.path) { - return + for (const displayUrl of cache.values()) { + revokeTerminalInlineImageObjectUrl(displayUrl) } + cache.clear() +} + +const terminalInlineImageLinkUrl = (entry: TerminalInlineImageEntry): string => + entry._tag === "AvailableTerminalInlineImage" ? entry.displayUrl : entry.fetchUrl +const terminalInlineImageTitle = (entry: TerminalInlineImageEntry): string => + entry._tag === "AvailableTerminalInlineImage" ? entry.path : `${entry.path} unavailable` + +const createTerminalInlineImageLink = (entry: TerminalInlineImageEntry): HTMLAnchorElement => { const link = document.createElement("a") - link.href = entry.fetchUrl + link.href = terminalInlineImageLinkUrl(entry) link.rel = "noreferrer" link.target = "_blank" - link.title = entry.path + link.title = terminalInlineImageTitle(entry) link.style.alignItems = "center" link.style.background = "#0d1218" link.style.border = "1px solid #3a4652" @@ -64,18 +126,80 @@ const renderInlineImageElement = ( link.style.padding = "4px" link.style.pointerEvents = "auto" link.style.width = `min(${terminalInlineImagePreviewWidthPx}px, 100%)` + return link +} +const appendAvailableTerminalInlineImage = ( + link: HTMLAnchorElement, + entry: Extract +): void => { const image = document.createElement("img") image.alt = entry.path - image.src = entry.fetchUrl + image.src = entry.displayUrl image.style.borderRadius = "4px" image.style.display = "block" image.style.height = "100%" image.style.objectFit = "contain" image.style.width = "100%" - link.append(image) +} + +const appendUnavailableTerminalInlineImage = (link: HTMLAnchorElement): void => { + const label = document.createElement("span") + label.textContent = "unavailable" + label.style.color = "#9aa8b6" + label.style.fontFamily = "'IBM Plex Mono', ui-monospace, monospace" + label.style.fontSize = "11px" + label.style.lineHeight = "1" + label.style.overflow = "hidden" + label.style.textOverflow = "ellipsis" + label.style.whiteSpace = "nowrap" + link.append(label) +} + +const appendTerminalInlineImageContent = ( + link: HTMLAnchorElement, + entry: TerminalInlineImageEntry +): void => { + if (entry._tag === "AvailableTerminalInlineImage") { + appendAvailableTerminalInlineImage(link, entry) + return + } + appendUnavailableTerminalInlineImage(link) +} + +const openImage = (fetchUrl: string): void => { + const imageWindow = window.open(fetchUrl, "_blank", "noopener,noreferrer") + if (imageWindow === null) { + return + } + imageWindow.opener = null +} + +const appendDecorationDisposable = ( + lifecycle: TerminalLifecycleState, + disposable: IDisposable +): void => { + lifecycle.inlineImageDisposables.push(disposable) + if (lifecycle.inlineImageDisposables.length <= terminalInlineImagePreviewLimit) { + return + } + lifecycle.inlineImageDisposables.shift()?.dispose() +} + +const renderInlineImageElement = ( + element: HTMLElement, + entry: TerminalInlineImageEntry +): void => { + if (element.dataset["path"] === entry.path && element.dataset["tag"] === entry._tag) { + return + } + + const link = createTerminalInlineImageLink(entry) + appendTerminalInlineImageContent(link, entry) + element.dataset["path"] = entry.path + element.dataset["tag"] = entry._tag element.style.pointerEvents = "none" element.replaceChildren(link) } diff --git a/packages/app/src/web/terminal-panel-runtime-core.ts b/packages/app/src/web/terminal-panel-runtime-core.ts index 4229ade9..5f40ddea 100644 --- a/packages/app/src/web/terminal-panel-runtime-core.ts +++ b/packages/app/src/web/terminal-panel-runtime-core.ts @@ -1,10 +1,19 @@ +import { FetchHttpClient, HttpClient } from "@effect/platform" import { Effect, Either } from "effect" import { Terminal } from "xterm" import { FitAddon } from "xterm-addon-fit" import { resolveTerminalImageFetchUrl } from "./terminal-image-url.js" import { splitTerminalInlineImageOutput, type TerminalInlineImageOutputSegment } from "./terminal-inline-images-core.js" -import { appendTerminalInlineImagePreview, terminalInlineImageSpacer } from "./terminal-inline-images.js" +import { + appendTerminalInlineImagePreview, + cachedTerminalInlineImageEntry, + cacheTerminalInlineImageBlob, + revokeTerminalInlineImageObjectUrlCache, + terminalInlineImageSpacer, + unavailableTerminalInlineImageEntry +} from "./terminal-inline-images.js" +import type { TerminalInlineImageEntry } from "./terminal-inline-images.js" import type { TerminalCleanupArgs, TerminalInputController, @@ -23,6 +32,11 @@ type TerminalClientMessage = | { readonly data: string; readonly type: "input" } | { readonly cols: number; readonly rows: number; readonly type: "resize" } +type TerminalInlineImageFetchError = { + readonly _tag: "TerminalInlineImageFetchError" + readonly message: string +} + const runOptionalTerminalOperation = (operation: () => void): boolean => Either.isRight( Effect.runSync( @@ -39,6 +53,7 @@ export const createLifecycleState = (): TerminalLifecycleState => ({ attachedOnce: false, disposed: false, inlineImageDisposables: [], + inlineImageObjectUrls: new Map(), outputQueue: [], outputWriting: false, readyNotified: false, @@ -183,11 +198,80 @@ const endTerminalSession = ( const terminalImageEntry = ( handlers: TerminalMessageHandlers, path: string -) => ({ - fetchUrl: resolveTerminalImageFetchUrl(handlers.session.websocketPath, path), - path +): TerminalInlineImageEntry | null => { + const fetchUrl = resolveTerminalImageFetchUrl(handlers.session.websocketPath, path) + return cachedTerminalInlineImageEntry(handlers.lifecycle.inlineImageObjectUrls, path, fetchUrl) +} + +const terminalInlineImageFetchError = (message: string): TerminalInlineImageFetchError => ({ + _tag: "TerminalInlineImageFetchError", + message }) +const terminalInlineImageFetchHeaders: Readonly> = { + accept: "image/*", + "cache-control": "no-cache, no-store, max-age=0", + pragma: "no-cache" +} + +const imageBlobFromArrayBuffer = ( + buffer: ArrayBuffer, + mediaType: string | undefined +): Blob => new Blob([buffer], mediaType === undefined ? {} : { type: mediaType }) + +const fetchTerminalInlineImageBlob = ( + fetchUrl: string +): Effect.Effect => + Effect.gen(function*(_) { + const client = yield* _(HttpClient.HttpClient) + const response = yield* _( + client.get(fetchUrl, { headers: terminalInlineImageFetchHeaders }).pipe( + Effect.mapError(() => terminalInlineImageFetchError("Could not fetch terminal image.")) + ) + ) + if (response.status >= 400) { + return yield* _(Effect.fail(terminalInlineImageFetchError(`Terminal image returned HTTP ${response.status}.`))) + } + const buffer = yield* _( + response.arrayBuffer.pipe( + Effect.mapError(() => terminalInlineImageFetchError("Could not read terminal image response.")) + ) + ) + return imageBlobFromArrayBuffer(buffer, response.headers["content-type"]) + }).pipe(Effect.provide(FetchHttpClient.layer)) + +const loadTerminalImageEntry = ( + handlers: TerminalMessageHandlers, + path: string, + onComplete: (entry: TerminalInlineImageEntry) => void +): void => { + const fetchUrl = resolveTerminalImageFetchUrl(handlers.session.websocketPath, path) + const cached = cachedTerminalInlineImageEntry(handlers.lifecycle.inlineImageObjectUrls, path, fetchUrl) + if (cached !== null) { + onComplete(cached) + return + } + Effect.runFork( + fetchTerminalInlineImageBlob(fetchUrl).pipe( + Effect.match({ + onFailure: () => unavailableTerminalInlineImageEntry(path, fetchUrl), + onSuccess: (blob) => + handlers.lifecycle.disposed + ? null + : cacheTerminalInlineImageBlob(handlers.lifecycle.inlineImageObjectUrls, path, fetchUrl, blob) + }), + Effect.flatMap((entry) => + Effect.sync(() => { + if (entry === null || handlers.lifecycle.disposed) { + return + } + onComplete(entry) + }) + ) + ) + ) +} + const writePreviewSpacer = ( handlers: TerminalMessageHandlers, onComplete: () => void @@ -199,11 +283,26 @@ const writeInlineImagePreview = ( handlers: TerminalMessageHandlers, path: string, onComplete: () => void +): void => { + const cached = terminalImageEntry(handlers, path) + if (cached !== null) { + writeInlineImagePreviewEntry(handlers, cached, onComplete) + return + } + loadTerminalImageEntry(handlers, path, (entry) => { + writeInlineImagePreviewEntry(handlers, entry, onComplete) + }) +} + +const writeInlineImagePreviewEntry = ( + handlers: TerminalMessageHandlers, + entry: TerminalInlineImageEntry, + onComplete: () => void ): void => { const appended = appendTerminalInlineImagePreview( handlers.terminal, handlers.lifecycle, - terminalImageEntry(handlers, path) + entry ) if (!appended) { onComplete() @@ -336,6 +435,7 @@ export const cleanupTerminalResources = ( disposable.dispose() } args.lifecycle.inlineImageDisposables = [] + revokeTerminalInlineImageObjectUrlCache(args.lifecycle.inlineImageObjectUrls) args.lifecycle.outputQueue = [] args.lifecycle.outputWriting = false args.removeImageLinks() diff --git a/packages/app/src/web/terminal-panel-runtime-types.ts b/packages/app/src/web/terminal-panel-runtime-types.ts index e180d6f7..f319ce12 100644 --- a/packages/app/src/web/terminal-panel-runtime-types.ts +++ b/packages/app/src/web/terminal-panel-runtime-types.ts @@ -19,6 +19,7 @@ export type TerminalLifecycleState = { attachedOnce: boolean disposed: boolean inlineImageDisposables: Array + inlineImageObjectUrls: Map outputQueue: Array outputWriting: boolean readyNotified: boolean diff --git a/packages/app/tests/docker-git/terminal-image-paths.test.ts b/packages/app/tests/docker-git/terminal-image-paths.test.ts index e9779502..ec30dc3b 100644 --- a/packages/app/tests/docker-git/terminal-image-paths.test.ts +++ b/packages/app/tests/docker-git/terminal-image-paths.test.ts @@ -7,6 +7,10 @@ import { stripTerminalAnsi } from "../../src/web/terminal-image-paths.js" +const issue250ImagePath = `/${["t", "mp"].join("")}/phantom-e2e.tuhl98/wallet-step-after-password.png` +const issue250DeleteCommand = `Ran rm -f ${issue250ImagePath}` +const issue250FileUrl = `file://${issue250ImagePath}` + describe("terminal image path detection", () => { it("detects a single absolute image path", () => { expect(detectTerminalImagePaths("see /var/data/issue232-main.png for details")).toEqual([ @@ -14,6 +18,14 @@ describe("terminal image path detection", () => { ]) }) + it("detects the local image path from issue 250 output", () => { + expect(detectTerminalImagePaths(issue250DeleteCommand)).toEqual([issue250ImagePath]) + }) + + it("detects file urls for absolute local image paths", () => { + expect(detectTerminalImagePaths(`open ${issue250FileUrl}`)).toEqual([issue250FileUrl]) + }) + it("returns match ranges for clickable image paths", () => { expect(detectTerminalImagePathMatches("see /var/data/a.png.")).toEqual([ { diff --git a/packages/app/tests/docker-git/terminal-inline-images-core.test.ts b/packages/app/tests/docker-git/terminal-inline-images-core.test.ts index 9dadbf1e..82e5f4b9 100644 --- a/packages/app/tests/docker-git/terminal-inline-images-core.test.ts +++ b/packages/app/tests/docker-git/terminal-inline-images-core.test.ts @@ -1,7 +1,19 @@ import { describe, expect, it } from "@effect/vitest" +import { vi } from "vitest" import { splitTerminalInlineImageOutput } from "../../src/web/terminal-inline-images-core.js" -import { terminalInlineImagePreviewRows, terminalInlineImageSpacer } from "../../src/web/terminal-inline-images.js" +import { + cachedTerminalInlineImageEntry, + cacheTerminalInlineImageBlob, + revokeTerminalInlineImageObjectUrlCache, + terminalInlineImagePreviewRows, + terminalInlineImageSpacer, + unavailableTerminalInlineImageEntry +} from "../../src/web/terminal-inline-images.js" + +const issue250ImagePath = `/${["t", "mp"].join("")}/phantom-e2e.tuhl98/wallet-step-after-password.png` +const issue250DeleteCommand = `Ran rm -f ${issue250ImagePath}` +const issue250FileUrl = `file://${issue250ImagePath}` describe("terminal inline image output", () => { it("keeps prompt output after a completed image path line in a later segment", () => { @@ -29,8 +41,69 @@ describe("terminal inline image output", () => { ]) }) + it("captures image paths from deletion command output", () => { + expect(splitTerminalInlineImageOutput(issue250DeleteCommand)).toEqual([ + { + endedWithLineBreak: false, + imagePaths: [issue250ImagePath], + text: issue250DeleteCommand + } + ]) + }) + + it("captures file url image paths", () => { + expect(splitTerminalInlineImageOutput(`saved ${issue250FileUrl}\r\n`)).toEqual([ + { + endedWithLineBreak: true, + imagePaths: [issue250FileUrl], + text: `saved ${issue250FileUrl}\r\n` + } + ]) + }) + it("keeps inline image previews compact in the terminal output stream", () => { expect(terminalInlineImagePreviewRows).toBe(4) expect(terminalInlineImageSpacer).toBe("\r\n\r\n\r\n\r\n") }) + + it("caches successful image fetch blobs as reusable object urls", () => { + const createObjectUrl = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:terminal-image") + const revokeObjectUrl = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => void 0) + const cache = new Map() + const blob = new Blob(["image"], { type: "image/png" }) + const imagePath = "/var/data/example.png" + + expect(cacheTerminalInlineImageBlob(cache, imagePath, "https://api/image", blob)).toEqual({ + _tag: "AvailableTerminalInlineImage", + displayUrl: "blob:terminal-image", + fetchUrl: "https://api/image", + path: imagePath + }) + expect(cachedTerminalInlineImageEntry(cache, imagePath, "https://api/image")).toEqual({ + _tag: "AvailableTerminalInlineImage", + displayUrl: "blob:terminal-image", + fetchUrl: "https://api/image", + path: imagePath + }) + expect(cacheTerminalInlineImageBlob(cache, imagePath, "https://api/image", blob)).toEqual({ + _tag: "AvailableTerminalInlineImage", + displayUrl: "blob:terminal-image", + fetchUrl: "https://api/image", + path: imagePath + }) + expect(createObjectUrl).toHaveBeenCalledTimes(1) + + revokeTerminalInlineImageObjectUrlCache(cache) + + expect(revokeObjectUrl).toHaveBeenCalledWith("blob:terminal-image") + expect(cache.size).toBe(0) + }) + + it("represents failed image fetches without using a broken image url", () => { + expect(unavailableTerminalInlineImageEntry("/var/data/missing.png", "https://api/image")).toEqual({ + _tag: "UnavailableTerminalInlineImage", + fetchUrl: "https://api/image", + path: "/var/data/missing.png" + }) + }) }) From c61aaf90f501aa93dd61144df171289113b13f99 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Fri, 8 May 2026 11:28:00 +0000 Subject: [PATCH 2/4] fix(web): satisfy full app checks --- packages/app/src/web/actions-event-payload.ts | 13 +++ .../app/src/web/actions-project-create.ts | 13 +-- packages/app/src/web/actions-projects.ts | 83 +++++++++---------- packages/app/src/web/api-types.ts | 5 +- .../tests/docker-git/actions-projects.test.ts | 48 +++++++---- .../app/tests/docker-git/controller.test.ts | 2 +- .../terminal-inline-images-core.test.ts | 21 ++--- 7 files changed, 94 insertions(+), 91 deletions(-) create mode 100644 packages/app/src/web/actions-event-payload.ts diff --git a/packages/app/src/web/actions-event-payload.ts b/packages/app/src/web/actions-event-payload.ts new file mode 100644 index 00000000..503748ce --- /dev/null +++ b/packages/app/src/web/actions-event-payload.ts @@ -0,0 +1,13 @@ +import type { ApiEvent } from "./api.js" + +export const readEventPayloadString = ( + event: ApiEvent, + key: string +): string | null => { + const payload = event.payload + if (payload === null || typeof payload !== "object" || Array.isArray(payload)) { + return null + } + const value = Object.entries(payload).find(([name]) => name === key)?.[1] + return typeof value === "string" ? value : null +} diff --git a/packages/app/src/web/actions-project-create.ts b/packages/app/src/web/actions-project-create.ts index 9c2f5eb6..c65140d9 100644 --- a/packages/app/src/web/actions-project-create.ts +++ b/packages/app/src/web/actions-project-create.ts @@ -3,6 +3,7 @@ import { Either } from "effect" import { createProjectDraftFromInputs } from "../docker-git/menu-create-shared.js" import type { CreateInputs } from "../docker-git/menu-types.js" +import { readEventPayloadString } from "./actions-event-payload.js" import { appendOutputLine, appendOutputLineHandler, notifyProjectEventRateLimit } from "./actions-output.js" import { type BrowserActionContext, withBusy } from "./actions-shared.js" import { ProjectDetailsSchema } from "./api-schema.js" @@ -10,18 +11,6 @@ import { type ApiEvent, loadProjectDetails, type ProjectDetails, startCreateProj import { openProjectEventStream } from "./project-events.js" import { outputScreen, projectPickerScreen } from "./screen.js" -const readEventPayloadString = ( - event: ApiEvent, - key: string -): string | null => { - const payload = event.payload - if (payload === null || typeof payload !== "object" || Array.isArray(payload)) { - return null - } - const value = Object.entries(payload).find(([name]) => name === key)?.[1] - return typeof value === "string" ? value : null -} - const readCreatedProjectId = (event: ApiEvent): string | null => event.type === "project.created" ? readEventPayloadString(event, "projectId") : null diff --git a/packages/app/src/web/actions-projects.ts b/packages/app/src/web/actions-projects.ts index 276c3b02..3a7f0c95 100644 --- a/packages/app/src/web/actions-projects.ts +++ b/packages/app/src/web/actions-projects.ts @@ -1,5 +1,6 @@ import { openSelectedProjectBrowser } from "./actions-browser.js" import { openSelectedProjectDatabaseEditor } from "./actions-databases.js" +import { readEventPayloadString } from "./actions-event-payload.js" import { appendOutputLine, appendOutputLineHandler, notifyProjectEventRateLimit } from "./actions-output.js" import { openSelectedProjectPort } from "./actions-port-forwards.js" import { @@ -13,9 +14,9 @@ import { } from "./actions-shared.js" import { loadSelectedProjectTasks } from "./actions-tasks.js" import { + type ApiEvent, applyAllProjects, applyProject, - type ApiEvent, deleteProject, downAllProjects, downProject, @@ -78,42 +79,39 @@ const resolveProjectTerminalKey = ( return null } -const randomHex = (bytes: number): string => { - const getRandomValues = globalThis.crypto?.getRandomValues - if (typeof getRandomValues === "function") { - const values = new Uint8Array(bytes) - getRandomValues.call(globalThis.crypto, values) - return Array.from(values, (value) => value.toString(16).padStart(2, "0")).join("") - } +type ProjectCrypto = Crypto & { + readonly randomUUID?: () => string +} - let fallback = "" - while (fallback.length < bytes * 2) { - fallback += Math.floor(Math.random() * 0x1_0000_0000) - .toString(16) - .padStart(8, "0") - } - return fallback.slice(0, bytes * 2) +const randomHex = (bytes: number): string => { + const values = new Uint8Array(bytes) + globalThis.crypto.getRandomValues(values) + return Array.from(values, (value) => value.toString(16).padStart(2, "0")).join("") } const createPendingTerminalSessionId = (): string => { - const randomUUID = globalThis.crypto?.randomUUID - if (typeof randomUUID === "function") { - return randomUUID.call(globalThis.crypto) + const crypto = globalThis.crypto as ProjectCrypto + if (typeof crypto.randomUUID === "function") { + return crypto.randomUUID() } return `pending-${Date.now().toString(16)}-${randomHex(8)}` } -const readEventPayloadString = ( - event: ApiEvent, - key: string -): string | null => { - const payload = event.payload - if (payload === null || typeof payload !== "object" || Array.isArray(payload)) { - return null - } - const value = Object.entries(payload).find(([name]) => name === key)?.[1] - return typeof value === "string" ? value : null +type ProjectActiveTerminalSessionArgs = Omit< + Parameters[0], + "onExit" | "onReady" +> + +const addProjectTerminalSession = ( + context: BrowserActionContext, + args: ProjectActiveTerminalSessionArgs +) => { + context.addTerminalSession(buildProjectActiveTerminalSession({ + ...args, + onExit: context.reloadDashboard, + onReady: context.reloadDashboard + })) } const readTerminalSessionCreatedId = ( @@ -188,6 +186,11 @@ export const connectProjectById = ( stream?.close() stream = null } + const showPendingTerminalError = (error: string) => { + pendingSessionFinalized = true + appendOutputLine(context, `[error] ${error}`) + context.addTerminalSession(renderPendingTerminalSession(error, "error")) + } const attachCreatedSession = (sessionId: string) => { if (attachedSessionId !== null) { return @@ -198,23 +201,19 @@ export const connectProjectById = ( effect: loadProjectTerminalSession(resolvedProjectKey, sessionId), label: "Attaching SSH terminal", onFailure: (error) => { - pendingSessionFinalized = true - appendOutputLine(context, `[error] ${error}`) - context.addTerminalSession(renderPendingTerminalSession(error, "error")) + showPendingTerminalError(error) closeStream() }, onSuccess: (session) => { pendingSessionFinalized = true context.reloadDashboard() context.closeTerminalSession(pendingSessionId) - context.addTerminalSession(buildProjectActiveTerminalSession({ - onExit: context.reloadDashboard, - onReady: context.reloadDashboard, + addProjectTerminalSession(context, { projectDisplayName, projectId, projectKey: resolvedProjectKey, session - })) + }) context.setMessage(`Project is ready. SSH terminal is connecting for ${projectDisplayName}.`) closeStream() } @@ -225,9 +224,7 @@ export const connectProjectById = ( effect: startProjectTerminalSession(resolvedProjectKey, pendingSessionId), label: "Opening SSH terminal", onFailure: (error) => { - pendingSessionFinalized = true - appendOutputLine(context, `[error] ${error}`) - context.addTerminalSession(renderPendingTerminalSession(error, "error")) + showPendingTerminalError(error) }, onSuccess: (accepted) => { appendOutputLine(context, `[ssh.prepare] SSH terminal request accepted (${accepted.requestId})`) @@ -237,9 +234,7 @@ export const connectProjectById = ( onEvent: (event) => { const failure = readTerminalStartupFailure(event, accepted.requestId) if (failure !== null) { - pendingSessionFinalized = true - appendOutputLine(context, `[error] ${failure}`) - context.addTerminalSession(renderPendingTerminalSession(failure, "error")) + showPendingTerminalError(failure) context.setMessage(failure) closeStream() return @@ -306,14 +301,12 @@ export const attachProjectTerminalById = ( effect: loadProjectTerminalSession(resolvedProjectKey, sessionId), label: "Attaching SSH terminal", onSuccess: (session) => { - context.addTerminalSession(buildProjectActiveTerminalSession({ - onExit: context.reloadDashboard, - onReady: context.reloadDashboard, + addProjectTerminalSession(context, { projectDisplayName, projectId, projectKey: resolvedProjectKey, session - })) + }) context.setMessage(`Attached SSH terminal for ${projectDisplayName}.`) } }) diff --git a/packages/app/src/web/api-types.ts b/packages/app/src/web/api-types.ts index 4e66774f..840fb37a 100644 --- a/packages/app/src/web/api-types.ts +++ b/packages/app/src/web/api-types.ts @@ -23,8 +23,9 @@ import type { export type ProjectSummary = Schema.Schema.Type export type ProjectDetails = Schema.Schema.Type export type CreateProjectAcceptedResponse = Schema.Schema.Type -export type StartProjectTerminalSessionAccepted = - Schema.Schema.Type +export type StartProjectTerminalSessionAccepted = Schema.Schema.Type< + typeof StartProjectTerminalSessionAcceptedResponseSchema +> export type ProjectPortForward = Schema.Schema.Type export type ProjectBrowserSession = Schema.Schema.Type export type ProjectDatabaseForward = Schema.Schema.Type diff --git a/packages/app/tests/docker-git/actions-projects.test.ts b/packages/app/tests/docker-git/actions-projects.test.ts index f7c2483d..5511c5e4 100644 --- a/packages/app/tests/docker-git/actions-projects.test.ts +++ b/packages/app/tests/docker-git/actions-projects.test.ts @@ -3,16 +3,25 @@ import { Effect } from "effect" import { afterEach, beforeEach, vi } from "vitest" import { applyProjectById, connectProjectById, runApplyAllProjects } from "../../src/web/actions-projects.js" -import type { ProjectDetails, StartProjectTerminalSessionAccepted, TerminalSession } from "../../src/web/api.js" +import type { + ProjectDetails, + startProjectTerminalSession, + StartProjectTerminalSessionAccepted, + TerminalSession +} from "../../src/web/api.js" +import type { openProjectEventStream } from "../../src/web/project-events.js" import type { ActiveTerminalSession } from "../../src/web/terminal.js" import { makeBrowserActionContext, waitForAssertion } from "./browser-action-context-fixture.js" +type OpenProjectEventStream = typeof openProjectEventStream +type StartProjectTerminalSession = typeof startProjectTerminalSession + const applyAllProjectsMock = vi.hoisted(() => vi.fn()) const applyProjectMock = vi.hoisted(() => vi.fn()) const eventStreamCloseMock = vi.hoisted(() => vi.fn()) const loadProjectTerminalSessionMock = vi.hoisted(() => vi.fn()) -const openProjectEventStreamMock = vi.hoisted(() => vi.fn()) -const startProjectTerminalSessionMock = vi.hoisted(() => vi.fn()) +const openProjectEventStreamMock = vi.hoisted(() => vi.fn()) +const startProjectTerminalSessionMock = vi.hoisted(() => vi.fn()) vi.mock("../../src/web/api.js", () => ({ applyAllProjects: applyAllProjectsMock, @@ -91,6 +100,15 @@ const startTerminalAccepted = (requestId: string): StartProjectTerminalSessionAc requestId }) +const makeSelectedProjectActionContext = ( + overrides: Parameters[0] = {} +) => + makeBrowserActionContext({ + selectedProjectId: "project-1", + selectedProjectKey: "octocat/hello-world", + ...overrides + }) + describe("web project actions", () => { beforeEach(() => { vi.restoreAllMocks() @@ -118,11 +136,9 @@ describe("web project actions", () => { openProjectEventStreamMock.mockImplementation(() => ({ close: eventStreamCloseMock })) const addTerminalSession = vi.fn<(session: ActiveTerminalSession) => void>() const closeTerminalSession = vi.fn<(sessionId: string) => void>() - const { context, reloadDashboard, setMessage } = makeBrowserActionContext({ + const { context, reloadDashboard, setMessage } = makeSelectedProjectActionContext({ addTerminalSession, - closeTerminalSession, - selectedProjectId: "project-1", - selectedProjectKey: "octocat/hello-world" + closeTerminalSession }) connectProjectById("project-1", context, "octocat/hello-world") @@ -194,19 +210,20 @@ describe("web project actions", () => { it.effect("starts SSH terminal creation when randomUUID is unavailable", () => Effect.gen(function*(_) { - const dateNowMock = vi.spyOn(Date, "now").mockReturnValue(0x1234) - const mathRandomMock = vi.spyOn(Math, "random").mockReturnValue(0.5) - vi.stubGlobal("crypto", {}) + const dateNowMock = vi.spyOn(Date, "now").mockReturnValue(0x12_34) + const deterministicBytes = Uint8Array.from([0x80, 0, 0, 0, 0x80, 0, 0, 0]) + vi.stubGlobal("crypto", { + getRandomValues: (values: Uint8Array) => { + values.set(deterministicBytes.subarray(0, values.length)) + return values + } + }) startProjectTerminalSessionMock.mockImplementation((_projectKey, requestId: string) => Effect.succeed(startTerminalAccepted(requestId)) ) openProjectEventStreamMock.mockImplementation(() => ({ close: eventStreamCloseMock })) const addTerminalSession = vi.fn<(session: ActiveTerminalSession) => void>() - const { context } = makeBrowserActionContext({ - addTerminalSession, - selectedProjectId: "project-1", - selectedProjectKey: "octocat/hello-world" - }) + const { context } = makeSelectedProjectActionContext({ addTerminalSession }) connectProjectById("project-1", context, "octocat/hello-world") @@ -218,7 +235,6 @@ describe("web project actions", () => { expect(requestId).toBe("pending-1234-8000000080000000") expect(addTerminalSession).toHaveBeenCalledTimes(1) expect(openProjectEventStreamMock).toHaveBeenCalledTimes(1) - mathRandomMock.mockRestore() dateNowMock.mockRestore() })) diff --git a/packages/app/tests/docker-git/controller.test.ts b/packages/app/tests/docker-git/controller.test.ts index 1196b376..1872c890 100644 --- a/packages/app/tests/docker-git/controller.test.ts +++ b/packages/app/tests/docker-git/controller.test.ts @@ -71,7 +71,7 @@ describe("controller reachability", () => { it.effect("detects remote Docker hosts", () => Effect.sync(() => { - expect(isRemoteDockerHost()).toBe(false) + expect(isRemoteDockerHost("")).toBe(false) expect(isRemoteDockerHost("unix:///var/run/docker.sock")).toBe(false) expect(isRemoteDockerHost("tcp://docker.example.test:2376")).toBe(true) expect(isRemoteDockerHost("ssh://docker@example.test")).toBe(true) diff --git a/packages/app/tests/docker-git/terminal-inline-images-core.test.ts b/packages/app/tests/docker-git/terminal-inline-images-core.test.ts index 82e5f4b9..cc6a3365 100644 --- a/packages/app/tests/docker-git/terminal-inline-images-core.test.ts +++ b/packages/app/tests/docker-git/terminal-inline-images-core.test.ts @@ -72,25 +72,16 @@ describe("terminal inline image output", () => { const cache = new Map() const blob = new Blob(["image"], { type: "image/png" }) const imagePath = "/var/data/example.png" - - expect(cacheTerminalInlineImageBlob(cache, imagePath, "https://api/image", blob)).toEqual({ + const expectedEntry = { _tag: "AvailableTerminalInlineImage", displayUrl: "blob:terminal-image", fetchUrl: "https://api/image", path: imagePath - }) - expect(cachedTerminalInlineImageEntry(cache, imagePath, "https://api/image")).toEqual({ - _tag: "AvailableTerminalInlineImage", - displayUrl: "blob:terminal-image", - fetchUrl: "https://api/image", - path: imagePath - }) - expect(cacheTerminalInlineImageBlob(cache, imagePath, "https://api/image", blob)).toEqual({ - _tag: "AvailableTerminalInlineImage", - displayUrl: "blob:terminal-image", - fetchUrl: "https://api/image", - path: imagePath - }) + } + + expect(cacheTerminalInlineImageBlob(cache, imagePath, "https://api/image", blob)).toEqual(expectedEntry) + expect(cachedTerminalInlineImageEntry(cache, imagePath, "https://api/image")).toEqual(expectedEntry) + expect(cacheTerminalInlineImageBlob(cache, imagePath, "https://api/image", blob)).toEqual(expectedEntry) expect(createObjectUrl).toHaveBeenCalledTimes(1) revokeTerminalInlineImageObjectUrlCache(cache) From cb547dd093357c4918cd7715028d2839c5f285de Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Fri, 8 May 2026 11:32:33 +0000 Subject: [PATCH 3/4] fix(web): satisfy effect lint --- packages/app/src/web/actions-projects.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/app/src/web/actions-projects.ts b/packages/app/src/web/actions-projects.ts index 3a7f0c95..c69bd0f9 100644 --- a/packages/app/src/web/actions-projects.ts +++ b/packages/app/src/web/actions-projects.ts @@ -79,10 +79,6 @@ const resolveProjectTerminalKey = ( return null } -type ProjectCrypto = Crypto & { - readonly randomUUID?: () => string -} - const randomHex = (bytes: number): string => { const values = new Uint8Array(bytes) globalThis.crypto.getRandomValues(values) @@ -90,9 +86,8 @@ const randomHex = (bytes: number): string => { } const createPendingTerminalSessionId = (): string => { - const crypto = globalThis.crypto as ProjectCrypto - if (typeof crypto.randomUUID === "function") { - return crypto.randomUUID() + if (Reflect.has(globalThis.crypto, "randomUUID")) { + return globalThis.crypto.randomUUID() } return `pending-${Date.now().toString(16)}-${randomHex(8)}` From 94c7843977ff7cdc349420a9dbf2fdf95f5ddd38 Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 9 May 2026 07:21:36 +0000 Subject: [PATCH 4/4] fix(deps): adapt to TypeScript 6.0 and sonarjs 4.x - Drop deprecated `baseUrl` from package tsconfigs and rewrite path mappings as relative paths so typecheck stays clean under TS 6.0 (which now errors on `baseUrl`). - Rename the zsh prompt's `short_pwd` local to `short_path` so the updated sonarjs `no-hardcoded-passwords` rule no longer matches the `pwd=...` pattern in the embedded shell snippet. --- packages/app/src/lib/core/templates-zsh.ts | 6 +++--- packages/app/tsconfig.json | 7 +++---- packages/docker-git-session-sync/tsconfig.json | 3 +-- packages/lib/src/core/templates-zsh.ts | 6 +++--- packages/lib/tsconfig.json | 3 +-- 5 files changed, 11 insertions(+), 14 deletions(-) diff --git a/packages/app/src/lib/core/templates-zsh.ts b/packages/app/src/lib/core/templates-zsh.ts index ceefa95e..635a787d 100644 --- a/packages/app/src/lib/core/templates-zsh.ts +++ b/packages/app/src/lib/core/templates-zsh.ts @@ -84,9 +84,9 @@ docker_git_prompt_apply() { docker_git_terminal_sanitize local b b="$(docker_git_branch)" - local short_pwd - short_pwd="$(docker_git_short_pwd)" - local base="[%*] $short_pwd" + local short_path + short_path="$(docker_git_short_pwd)" + local base="[%*] $short_path" if [[ -n "$b" ]]; then PROMPT="$base ($b)> " else diff --git a/packages/app/tsconfig.json b/packages/app/tsconfig.json index 58c0fd24..ac6e0290 100644 --- a/packages/app/tsconfig.json +++ b/packages/app/tsconfig.json @@ -7,11 +7,10 @@ "types": ["vitest", "vite/client"], "lib": ["ES2023", "DOM", "DOM.Iterable"], "jsx": "react-jsx", - "baseUrl": ".", "paths": { - "@/*": ["src/*"], - "@lib": ["src/lib/index.ts"], - "@lib/*": ["src/lib/*.ts"] + "@/*": ["./src/*"], + "@lib": ["./src/lib/index.ts"], + "@lib/*": ["./src/lib/*.ts"] } }, "include": [ diff --git a/packages/docker-git-session-sync/tsconfig.json b/packages/docker-git-session-sync/tsconfig.json index eb355b16..8e8358c6 100644 --- a/packages/docker-git-session-sync/tsconfig.json +++ b/packages/docker-git-session-sync/tsconfig.json @@ -3,8 +3,7 @@ "compilerOptions": { "rootDir": ".", "outDir": "dist", - "types": ["vitest", "node"], - "baseUrl": "." + "types": ["vitest", "node"] }, "include": ["src/**/*", "tests/**/*", "vite.config.ts"], "exclude": ["dist", "node_modules"] diff --git a/packages/lib/src/core/templates-zsh.ts b/packages/lib/src/core/templates-zsh.ts index ceefa95e..635a787d 100644 --- a/packages/lib/src/core/templates-zsh.ts +++ b/packages/lib/src/core/templates-zsh.ts @@ -84,9 +84,9 @@ docker_git_prompt_apply() { docker_git_terminal_sanitize local b b="$(docker_git_branch)" - local short_pwd - short_pwd="$(docker_git_short_pwd)" - local base="[%*] $short_pwd" + local short_path + short_path="$(docker_git_short_pwd)" + local base="[%*] $short_path" if [[ -n "$b" ]]; then PROMPT="$base ($b)> " else diff --git a/packages/lib/tsconfig.json b/packages/lib/tsconfig.json index 028caa14..788d2ea0 100644 --- a/packages/lib/tsconfig.json +++ b/packages/lib/tsconfig.json @@ -6,9 +6,8 @@ "declaration": true, "declarationMap": true, "types": ["vitest", "node"], - "baseUrl": ".", "paths": { - "@/*": ["src/*"] + "@/*": ["./src/*"] } }, "include": ["src/**/*"],