diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 75cec0ff9c6..98cbc681b75 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -9,6 +9,8 @@ # -github:username reason for denouncement # # Keep entries sorted alphabetically. +github:0x4bs3nt +github:Adamulek123 github:adityavardhansharma github:arhxam github:bil0000 @@ -26,6 +28,7 @@ github:github-actions[bot] github:gsimone github:GuilhermeVieiraDev github:hwanseoc +github:ipanasenko github:jakeleventhal github:jamesx0416 github:jappyjan @@ -37,7 +40,9 @@ github:lnieuwenhuis github:Lucenx9 github:mackinleysmith github:maria-rcks +github:maxwellyoung github:mwolson +github:nateEc github:nmggithub github:Noojuno github:notkainoa @@ -57,6 +62,7 @@ github:StiensWout github:SunkenInTime github:tarik02 github:tris203 +github:tsouth89 github:UtkarshUsername github:Yash-Singh1 github:yashranaway diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 32035467933..1ba833445cb 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.50", + "version": "0.0.51", "private": true, "type": "module", "main": "dist-electron/main.cjs", diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 46675d70b5a..6b126762e6c 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -11,6 +11,7 @@ import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; +import * as DesktopAppActivation from "./DesktopAppActivation.ts"; import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; @@ -148,6 +149,7 @@ const bootstrap = Effect.gen(function* () { const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; const desktopWindow = yield* DesktopWindow.DesktopWindow; + const appActivation = yield* DesktopAppActivation.DesktopAppActivation; yield* logBootstrapInfo("bootstrap start"); if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) { @@ -210,6 +212,10 @@ const bootstrap = Effect.gen(function* () { } yield* primaryBackend.start; yield* logBootstrapInfo("bootstrap backend start requested"); + yield* appActivation.start.pipe( + Effect.tap(() => logBootstrapInfo("desktop app control socket ready")), + Effect.catch((error) => logStartupError("desktop app control socket unavailable", { error })), + ); // Bring up the WSL backend if the user previously enabled it. The // primary is already starting; reconcile fires off the WSL register // in parallel rather than blocking primary readiness on a possibly diff --git a/apps/desktop/src/app/DesktopAppActivation.test.ts b/apps/desktop/src/app/DesktopAppActivation.test.ts new file mode 100644 index 00000000000..d6ce8032279 --- /dev/null +++ b/apps/desktop/src/app/DesktopAppActivation.test.ts @@ -0,0 +1,140 @@ +// @effect-diagnostics nodeBuiltinImport:off -- This adapter test binds a real local socket or Windows named pipe and verifies its cleanup. +import * as NodeFSP from "node:fs/promises"; +import * as NodeNet from "node:net"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { + ProjectId, + ThreadId, + type DesktopAppActivationRequest, + type DesktopAppActivationResponse, +} from "@t3tools/contracts"; +import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl"; +import { HostProcessPlatform, HostProcessUserId } from "@t3tools/shared/hostProcess"; +import { it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { afterEach, describe, expect } from "vite-plus/test"; + +import { startDesktopAppControlServer } from "./DesktopAppActivation.ts"; + +const openServers: Array<{ close: () => Promise }> = []; + +afterEach(async () => { + await Promise.all(openServers.splice(0).map((server) => server.close())); +}); + +function makeTarget(stateDir: string, platform: NodeJS.Platform, userId: number | undefined) { + return resolveDesktopAppControlAddress({ + stateDir, + platform, + tempDir: NodeOS.tmpdir(), + userId, + joinPath: NodePath.join, + }); +} + +function request(requestId: string, platform: NodeJS.Platform): DesktopAppActivationRequest { + return { + version: 1, + requestId, + type: "open-workspace", + workspaceRoot: NodePath.join(NodeOS.tmpdir(), "project"), + platform: platform === "win32" ? "win32" : platform === "darwin" ? "darwin" : "linux", + }; +} + +function exchange(address: string, payload: DesktopAppActivationRequest) { + return new Promise((resolve, reject) => { + const socket = NodeNet.createConnection(address); + socket.setEncoding("utf8"); + let buffer = ""; + socket.once("error", reject); + socket.once("connect", () => socket.write(`${JSON.stringify(payload)}\n`)); + socket.on("data", (chunk) => { + buffer += chunk; + const newline = buffer.indexOf("\n"); + if (newline === -1) return; + socket.destroy(); + resolve(JSON.parse(buffer.slice(0, newline)) as DesktopAppActivationResponse); + }); + }); +} + +describe("desktop app control server", () => { + it.effect("roundtrips a request and removes its socket on shutdown", () => + Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + const userId = yield* HostProcessUserId; + yield* Effect.promise(async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-app-control-test-")); + const target = makeTarget(NodePath.join(root, "userdata"), platform, userId); + const received: DesktopAppActivationRequest[] = []; + const server = await startDesktopAppControlServer({ + ...target, + userId, + handle: async (input) => { + received.push(input); + return { + version: 1, + requestId: input.requestId, + ok: true, + projectId: ProjectId.make("project-1"), + threadId: ThreadId.make("thread-1"), + }; + }, + cancel: () => undefined, + }); + openServers.push(server); + + const response = await exchange(target.address, request("request-1", platform)); + + expect(received).toHaveLength(1); + expect(response).toMatchObject({ ok: true, requestId: "request-1" }); + await server.close(); + openServers.splice(openServers.indexOf(server), 1); + if (target.directory !== null) { + await expect(NodeFSP.stat(target.address)).rejects.toMatchObject({ code: "ENOENT" }); + } + await NodeFSP.rm(root, { recursive: true, force: true }); + }); + }), + ); + + it.effect("cancels a queued request when the client disconnects", () => + Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + const userId = yield* HostProcessUserId; + yield* Effect.promise(async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-app-cancel-test-")); + const target = makeTarget(NodePath.join(root, "userdata"), platform, userId); + let resolveCanceled: (requestId: string) => void = () => undefined; + const canceled = new Promise((resolve) => { + resolveCanceled = resolve; + }); + const server = await startDesktopAppControlServer({ + ...target, + userId, + handle: () => new Promise(() => undefined), + cancel: resolveCanceled, + }); + openServers.push(server); + const socket = NodeNet.createConnection(target.address); + await new Promise((resolve, reject) => { + socket.once("error", reject); + socket.once("connect", () => { + socket.write(`${JSON.stringify(request("request-canceled", platform))}\n`, () => { + socket.destroy(); + resolve(); + }); + }); + }); + + await expect(canceled).resolves.toBe("request-canceled"); + await server.close(); + openServers.splice(openServers.indexOf(server), 1); + await NodeFSP.rm(root, { recursive: true, force: true }); + }); + }), + ); +}); diff --git a/apps/desktop/src/app/DesktopAppActivation.ts b/apps/desktop/src/app/DesktopAppActivation.ts new file mode 100644 index 00000000000..f63fdffedae --- /dev/null +++ b/apps/desktop/src/app/DesktopAppActivation.ts @@ -0,0 +1,306 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Local socket ownership checks need lstat uid and an atomic stale-socket unlink at the Node adapter boundary. +import * as NodeFSP from "node:fs/promises"; +import * as NodeNet from "node:net"; +import * as NodeOS from "node:os"; + +import { + DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION, + DesktopAppActivationRequest, + type DesktopAppActivationResponse, +} from "@t3tools/contracts"; +import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl"; +import { HostProcessUserId } from "@t3tools/shared/hostProcess"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; + +import type * as Electron from "electron"; + +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import { DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL } from "../ipc/channels.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; +import { DesktopAppActivationBroker } from "./DesktopAppActivationBroker.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import { makeComponentLogger } from "./DesktopObservability.ts"; + +const MAX_REQUEST_BYTES = 64 * 1024; +const REQUEST_TIMEOUT_MS = 15_000; +const isDesktopAppActivationRequest = Schema.is(DesktopAppActivationRequest); + +export class DesktopAppActivationStartError extends Schema.TaggedErrorClass()( + "DesktopAppActivationStartError", + { + address: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not start the desktop app control socket at ${this.address}.`; + } +} + +interface RunningControlServer { + readonly close: () => Promise; +} + +function invalidResponse(requestId: string, message: string): DesktopAppActivationResponse { + return { + version: DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION, + requestId, + ok: false, + code: "invalid-request", + message, + }; +} + +function requestIdFromUnknown(value: unknown): string { + if ( + typeof value === "object" && + value !== null && + "requestId" in value && + typeof value.requestId === "string" && + value.requestId.trim().length > 0 + ) { + return value.requestId; + } + return "invalid-request"; +} + +async function prepareUnixSocket(input: { + readonly address: string; + readonly directory: string; + readonly userId: number | undefined; +}): Promise { + await NodeFSP.mkdir(input.directory, { recursive: true, mode: 0o700 }); + const stat = await NodeFSP.lstat(input.directory); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error(`${input.directory} is not a directory.`); + } + if (input.userId !== undefined && stat.uid !== input.userId) { + throw new Error(`${input.directory} is owned by another user.`); + } + await NodeFSP.chmod(input.directory, 0o700); + await NodeFSP.unlink(input.address).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + }); +} + +export async function startDesktopAppControlServer(input: { + readonly address: string; + readonly directory: string | null; + readonly userId: number | undefined; + readonly handle: (request: DesktopAppActivationRequest) => Promise; + readonly cancel: (requestId: string) => void; +}): Promise { + if (input.directory !== null) { + await prepareUnixSocket({ + address: input.address, + directory: input.directory, + userId: input.userId, + }); + } + + const sockets = new Set(); + const server = NodeNet.createServer((socket) => { + sockets.add(socket); + socket.setEncoding("utf8"); + let buffer = ""; + let handled = false; + let responseSent = false; + let activeRequestId: string | null = null; + + socket.setTimeout(5_000, () => socket.destroy()); + + const finish = (response: DesktopAppActivationResponse) => { + responseSent = true; + if (!socket.destroyed) socket.end(`${JSON.stringify(response)}\n`); + }; + + socket.on("data", (chunk) => { + if (handled) return; + buffer += chunk; + if (Buffer.byteLength(buffer, "utf8") > MAX_REQUEST_BYTES) { + handled = true; + finish(invalidResponse("invalid-request", "The desktop app request is too large.")); + return; + } + + const newline = buffer.indexOf("\n"); + if (newline === -1) return; + handled = true; + socket.setTimeout(0); + const line = buffer.slice(0, newline); + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + finish(invalidResponse("invalid-request", "The desktop app request is not valid JSON.")); + return; + } + + if (!isDesktopAppActivationRequest(parsed)) { + finish( + invalidResponse(requestIdFromUnknown(parsed), "The desktop app request is invalid."), + ); + return; + } + activeRequestId = parsed.requestId; + void input.handle(parsed).then(finish, () => { + finish( + invalidResponse(parsed.requestId, "T3 Code could not process the desktop app request."), + ); + }); + }); + socket.on("error", () => socket.destroy()); + socket.on("close", () => { + sockets.delete(socket); + if (!responseSent && activeRequestId !== null) input.cancel(activeRequestId); + }); + }); + + await new Promise((resolve, reject) => { + const onError = (error: Error) => { + server.removeListener("listening", onListening); + reject(error); + }; + const onListening = () => { + server.removeListener("error", onError); + resolve(); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(input.address); + }); + + try { + if (input.directory !== null) { + await NodeFSP.chmod(input.address, 0o600); + } + } catch (error) { + await new Promise((resolve) => server.close(() => resolve())); + throw error; + } + + let closed = false; + return { + close: async () => { + if (closed) return; + closed = true; + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + server.removeAllListeners(); + if (input.directory !== null) { + await NodeFSP.unlink(input.address).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + }); + } + }, + }; +} + +export class DesktopAppActivation extends Context.Service< + DesktopAppActivation, + { + readonly start: Effect.Effect; + readonly setRendererReady: (ready: boolean) => Effect.Effect; + readonly complete: (response: DesktopAppActivationResponse) => Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopAppActivation") {} + +const { logWarning } = makeComponentLogger("desktop-app-activation"); + +export const make = Effect.gen(function* () { + const desktopEnvironment = yield* DesktopEnvironment.DesktopEnvironment; + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const path = yield* Path.Path; + const userId = yield* HostProcessUserId; + const runPromise = Effect.runPromiseWith(yield* Effect.context()); + const address = resolveDesktopAppControlAddress({ + stateDir: path.resolve(desktopEnvironment.stateDir), + platform: desktopEnvironment.platform, + tempDir: NodeOS.tmpdir(), + userId, + joinPath: path.join, + }); + let registeredWebContents: Electron.WebContents | null = null; + let detachRendererListeners: (() => void) | null = null; + + const broker = new DesktopAppActivationBroker({ + requestTimeoutMs: REQUEST_TIMEOUT_MS, + activate: () => { + void runPromise( + desktopWindow.activate.pipe( + Effect.catchCause((cause) => logWarning("failed to focus the desktop window", { cause })), + ), + ); + }, + }); + + const clearRegisteredRenderer = () => { + detachRendererListeners?.(); + detachRendererListeners = null; + registeredWebContents = null; + broker.clearRenderer(); + }; + + return DesktopAppActivation.of({ + start: Effect.acquireRelease( + Effect.tryPromise({ + try: () => + startDesktopAppControlServer({ + ...address, + userId, + handle: (request) => broker.request(request), + cancel: (requestId) => broker.cancel(requestId), + }), + catch: (cause) => new DesktopAppActivationStartError({ address: address.address, cause }), + }), + (server) => + Effect.promise(() => server.close()).pipe( + Effect.catchCause((cause) => + logWarning("failed to close the desktop app control socket", { cause }), + ), + Effect.ensuring(Effect.sync(() => broker.close())), + ), + ).pipe(Effect.asVoid), + setRendererReady: Effect.fn("DesktopAppActivation.setRendererReady")(function* (ready) { + if (!ready) { + clearRegisteredRenderer(); + return; + } + const main = yield* electronWindow.main; + if (Option.isNone(main)) return; + const webContents = main.value.webContents; + if (webContents.isDestroyed()) return; + + if (registeredWebContents !== webContents) { + clearRegisteredRenderer(); + registeredWebContents = webContents; + const onUnavailable = () => clearRegisteredRenderer(); + const onNavigation = ( + event: Electron.Event, + ) => { + if (event.isMainFrame && !event.isSameDocument) clearRegisteredRenderer(); + }; + webContents.on("did-start-navigation", onNavigation); + webContents.once("destroyed", onUnavailable); + detachRendererListeners = () => { + webContents.removeListener("did-start-navigation", onNavigation); + webContents.removeListener("destroyed", onUnavailable); + }; + } + + broker.registerRenderer((request) => { + webContents.send(DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL, request); + }); + }), + complete: (response) => Effect.sync(() => broker.complete(response)), + }); +}); + +export const layer = Layer.effect(DesktopAppActivation, make); diff --git a/apps/desktop/src/app/DesktopAppActivationBroker.test.ts b/apps/desktop/src/app/DesktopAppActivationBroker.test.ts new file mode 100644 index 00000000000..7a889c2e91d --- /dev/null +++ b/apps/desktop/src/app/DesktopAppActivationBroker.test.ts @@ -0,0 +1,130 @@ +import { ProjectId, ThreadId, type DesktopAppActivationRequest } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { DesktopAppActivationBroker } from "./DesktopAppActivationBroker.ts"; + +const request: DesktopAppActivationRequest = { + version: 1, + requestId: "request-1", + type: "open-workspace", + workspaceRoot: "/workspace/project", + platform: "linux", +}; + +describe("DesktopAppActivationBroker", () => { + it("focuses immediately and waits for renderer readiness", async () => { + const activate = vi.fn(); + const send = vi.fn(); + const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate }); + + const response = broker.request(request); + expect(activate).toHaveBeenCalledOnce(); + expect(send).not.toHaveBeenCalled(); + + broker.registerRenderer(send); + expect(send).toHaveBeenCalledWith(request); + broker.complete({ + version: 1, + requestId: request.requestId, + ok: true, + projectId: ProjectId.make("project-1"), + threadId: ThreadId.make("thread-1"), + }); + + await expect(response).resolves.toMatchObject({ ok: true, projectId: "project-1" }); + broker.close(); + }); + + it("fails an in-flight request when the renderer goes away", async () => { + const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() }); + broker.registerRenderer(vi.fn()); + + const response = broker.request(request); + broker.clearRenderer(); + + await expect(response).resolves.toMatchObject({ + ok: false, + code: "renderer-unavailable", + }); + broker.close(); + }); + + it("queues requests after unsubscribe until a new renderer registers", async () => { + const previousSend = vi.fn(); + const nextSend = vi.fn(); + const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() }); + broker.registerRenderer(previousSend); + broker.clearRenderer(); + + const response = broker.request(request); + expect(previousSend).not.toHaveBeenCalled(); + expect(nextSend).not.toHaveBeenCalled(); + + broker.registerRenderer(nextSend); + expect(nextSend).toHaveBeenCalledWith(request); + broker.complete({ + version: 1, + requestId: request.requestId, + ok: true, + projectId: ProjectId.make("project-1"), + threadId: ThreadId.make("thread-1"), + }); + + await expect(response).resolves.toMatchObject({ ok: true }); + broker.close(); + }); + + it("removes a queued request when its CLI connection closes", async () => { + const send = vi.fn(); + const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() }); + + const response = broker.request(request); + broker.cancel(request.requestId); + broker.registerRenderer(send); + + await expect(response).resolves.toMatchObject({ ok: false, code: "renderer-unavailable" }); + expect(send).not.toHaveBeenCalled(); + broker.close(); + }); + + it("never sends a canceled request that was queued behind another request", async () => { + const send = vi.fn(); + const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() }); + broker.registerRenderer(send); + const secondRequest = { ...request, requestId: "request-2" }; + + const firstResponse = broker.request(request); + const secondResponse = broker.request(secondRequest); + expect(send).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenLastCalledWith(request); + + broker.cancel(secondRequest.requestId); + broker.complete({ + version: 1, + requestId: request.requestId, + ok: true, + projectId: ProjectId.make("project-1"), + threadId: ThreadId.make("thread-1"), + }); + + await expect(firstResponse).resolves.toMatchObject({ ok: true }); + await expect(secondResponse).resolves.toMatchObject({ ok: false }); + expect(send).toHaveBeenCalledTimes(1); + broker.close(); + }); + + it("times out a request without polling", async () => { + vi.useFakeTimers(); + try { + const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() }); + const response = broker.request(request); + + await vi.advanceTimersByTimeAsync(1_000); + + await expect(response).resolves.toMatchObject({ ok: false, code: "request-timeout" }); + broker.close(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/apps/desktop/src/app/DesktopAppActivationBroker.ts b/apps/desktop/src/app/DesktopAppActivationBroker.ts new file mode 100644 index 00000000000..221df9ca86d --- /dev/null +++ b/apps/desktop/src/app/DesktopAppActivationBroker.ts @@ -0,0 +1,146 @@ +// @effect-diagnostics globalTimers:off -- This protocol broker owns cancellable request deadlines outside the Effect runtime. +import { + DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION, + type DesktopAppActivationFailure, + type DesktopAppActivationRequest, + type DesktopAppActivationResponse, +} from "@t3tools/contracts"; + +interface PendingActivation { + readonly request: DesktopAppActivationRequest; + readonly resolve: (response: DesktopAppActivationResponse) => void; + readonly timeout: ReturnType; + dispatched: boolean; +} + +type RendererSender = (request: DesktopAppActivationRequest) => void; + +function failure( + requestId: string, + code: DesktopAppActivationFailure["code"], + message: string, +): DesktopAppActivationFailure { + return { + version: DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION, + requestId, + ok: false, + code, + message, + }; +} + +/** Holds CLI requests until the real desktop renderer is ready to handle them. */ +export class DesktopAppActivationBroker { + readonly #pending = new Map(); + readonly #requestTimeoutMs: number; + readonly #activate: () => void; + #renderer: RendererSender | null = null; + #closed = false; + + constructor(input: { readonly requestTimeoutMs: number; readonly activate: () => void }) { + this.#requestTimeoutMs = input.requestTimeoutMs; + this.#activate = input.activate; + } + + request(request: DesktopAppActivationRequest): Promise { + if (this.#closed) { + return Promise.resolve( + failure(request.requestId, "renderer-unavailable", "T3 Code is shutting down."), + ); + } + if (this.#pending.has(request.requestId)) { + return Promise.resolve( + failure(request.requestId, "invalid-request", "The request id is already in use."), + ); + } + + const response = new Promise((resolve) => { + const timeout = setTimeout(() => { + this.#settle( + failure( + request.requestId, + "request-timeout", + "The desktop app did not finish opening the project in time.", + ), + ); + }, this.#requestTimeoutMs); + this.#pending.set(request.requestId, { + request, + resolve, + timeout, + dispatched: false, + }); + }); + + this.#activate(); + this.#flush(); + return response; + } + + registerRenderer(send: RendererSender): void { + this.#renderer = send; + this.#flush(); + } + + clearRenderer(): void { + this.#renderer = null; + for (const pending of this.#pending.values()) { + if (pending.dispatched) { + this.#settle( + failure( + pending.request.requestId, + "renderer-unavailable", + "The T3 Code window closed before it opened the project.", + ), + ); + } + } + } + + complete(response: DesktopAppActivationResponse): void { + this.#settle(response); + } + + cancel(requestId: string): void { + this.#settle( + failure(requestId, "renderer-unavailable", "The command closed before T3 Code was ready."), + ); + } + + close(): void { + this.#closed = true; + this.#renderer = null; + for (const pending of this.#pending.values()) { + this.#settle( + failure(pending.request.requestId, "renderer-unavailable", "T3 Code is shutting down."), + ); + } + } + + #flush(): void { + const renderer = this.#renderer; + if (renderer === null) return; + if ([...this.#pending.values()].some((pending) => pending.dispatched)) return; + + for (const pending of this.#pending.values()) { + if (pending.dispatched) continue; + try { + pending.dispatched = true; + renderer(pending.request); + } catch { + pending.dispatched = false; + this.#renderer = null; + } + return; + } + } + + #settle(response: DesktopAppActivationResponse): void { + const pending = this.#pending.get(response.requestId); + if (!pending) return; + clearTimeout(pending.timeout); + this.#pending.delete(response.requestId); + pending.resolve(response); + this.#flush(); + } +} diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 585dd7e7ea1..a5c03e0b933 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -225,7 +225,7 @@ describe("ElectronProtocol", () => { "http:", "https:", ]); - assert.deepEqual(directives["media-src"], ["'self'", "t3code:", "blob:"]); + assert.deepEqual(directives["media-src"], ["'self'", "t3code:", "blob:", "http:", "https:"]); assert.deepEqual(directives["font-src"], ["'self'", "t3code:", "data:"]); }); }); diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 845f03dba5d..fabd598d7ff 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -87,7 +87,7 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat `script-src ${scriptSources.join(" ")}`, `connect-src ${connectSources.join(" ")}`, `img-src 'self' ${input.scheme}: blob: data: http: https:`, - `media-src 'self' ${input.scheme}: blob:`, + `media-src 'self' ${input.scheme}: blob: http: https:`, "style-src 'self' 'unsafe-inline'", `font-src 'self' ${input.scheme}: data:`, "worker-src 'self' blob:", @@ -118,6 +118,7 @@ export function registerDesktopSchemePrivilegesSync(): void { secure: true, supportFetchAPI: true, corsEnabled: true, + stream: true, }, }, { @@ -127,6 +128,7 @@ export function registerDesktopSchemePrivilegesSync(): void { secure: true, supportFetchAPI: true, corsEnabled: true, + stream: true, }, }, ]); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 95af10836d0..2da23dad361 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -46,12 +46,16 @@ import { showContextMenu, } from "./methods/window.ts"; import * as PreviewIpc from "./methods/preview.ts"; +import * as AppActivationIpc from "./methods/appActivation.ts"; import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts"; export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { const ipc = yield* DesktopIpc.DesktopIpc; yield* PreviewIpc.installPreviewEventForwarding(); + yield* ipc.handle(AppActivationIpc.setReady); + yield* ipc.handle(AppActivationIpc.complete); + yield* ipc.handleSync(getAppBranding); yield* ipc.handleSync(getSystemLocale); yield* ipc.handleSync(getWindowFullscreenState); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index d247597e7e1..4f0489443b5 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -9,6 +9,9 @@ export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; +export const DESKTOP_APP_ACTIVATION_READY_CHANNEL = "desktop:app-activation-ready"; +export const DESKTOP_APP_ACTIVATION_COMPLETE_CHANNEL = "desktop:app-activation-complete"; +export const DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL = "desktop:app-activation-request"; export const UPDATE_STATE_CHANNEL = "desktop:update-state"; export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; export const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; diff --git a/apps/desktop/src/ipc/methods/appActivation.ts b/apps/desktop/src/ipc/methods/appActivation.ts new file mode 100644 index 00000000000..b5e659b235d --- /dev/null +++ b/apps/desktop/src/ipc/methods/appActivation.ts @@ -0,0 +1,27 @@ +import { DesktopAppActivationResponse } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as DesktopAppActivation from "../../app/DesktopAppActivation.ts"; +import * as IpcChannels from "../channels.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; + +export const setReady = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.DESKTOP_APP_ACTIVATION_READY_CHANNEL, + payload: Schema.Boolean, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.appActivation.setReady")(function* (ready) { + const activation = yield* DesktopAppActivation.DesktopAppActivation; + yield* activation.setRendererReady(ready); + }), +}); + +export const complete = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.DESKTOP_APP_ACTIVATION_COMPLETE_CHANNEL, + payload: DesktopAppActivationResponse, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.appActivation.complete")(function* (response) { + const activation = yield* DesktopAppActivation.DesktopAppActivation; + yield* activation.complete(response); + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 14caeed8a9a..c826c56e1a7 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -32,6 +32,7 @@ import * as ElectronTheme from "./electron/ElectronTheme.ts"; import * as ElectronUpdater from "./electron/ElectronUpdater.ts"; import * as ElectronWindow from "./electron/ElectronWindow.ts"; import * as DesktopApp from "./app/DesktopApp.ts"; +import * as DesktopAppActivation from "./app/DesktopAppActivation.ts"; import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; import * as DesktopClerk from "./app/DesktopClerk.ts"; @@ -157,6 +158,10 @@ const desktopWindowLayer = DesktopWindow.layer.pipe( Layer.provideMerge(desktopPreviewLayer), ); +const desktopAppActivationLayer = DesktopAppActivation.layer.pipe( + Layer.provide(desktopWindowLayer), +); + // Pool layer instantiates the backend factory once for the Windows // primary instance and exposes it via pool.primary. Consumers go through // the pool now; the legacy DesktopBackendManager service is gone. The @@ -184,6 +189,7 @@ const desktopLocalEnvironmentAuthLayer = DesktopLocalEnvironmentAuth.layer.pipe( const desktopApplicationLayer = Layer.mergeAll( DesktopLifecycle.layer, + desktopAppActivationLayer, DesktopApplicationMenu.layer, DesktopLinuxUrlHandler.layer, DesktopShellEnvironment.layer, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index ffb82e023ba..95d24d67b0a 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -182,6 +182,25 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener); }; }, + appActivation: { + setReady: (ready) => + ipcRenderer.invoke(IpcChannels.DESKTOP_APP_ACTIVATION_READY_CHANNEL, ready), + complete: (response) => + ipcRenderer.invoke(IpcChannels.DESKTOP_APP_ACTIVATION_COMPLETE_CHANNEL, response), + onRequest: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, request: unknown) => { + if (typeof request !== "object" || request === null) return; + listener(request as Parameters[0]); + }; + ipcRenderer.on(IpcChannels.DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener( + IpcChannels.DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL, + wrappedListener, + ); + }; + }, + }, preview: { createTab: (tabId, defaults) => ipcRenderer.invoke(IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, { diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_video.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_video.png new file mode 100644 index 00000000000..673c95d8b36 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_video.png differ diff --git a/apps/mobile/modules/t3-markdown-text/package.json b/apps/mobile/modules/t3-markdown-text/package.json index d51b6c5d9ff..7ab3f1fbde8 100644 --- a/apps/mobile/modules/t3-markdown-text/package.json +++ b/apps/mobile/modules/t3-markdown-text/package.json @@ -26,6 +26,8 @@ "./types": "./src/SelectableMarkdownText.types.ts" }, "peerDependencies": { + "@t3tools/client-runtime": "*", + "@t3tools/shared": "*", "expo-asset": "*", "expo-clipboard": "*", "expo-haptics": "*", diff --git a/apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs b/apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs index 2c2cc43bc65..8510ce97ba6 100644 --- a/apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs +++ b/apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs @@ -66,6 +66,7 @@ const colors = { terraform: "#693acf", text: "#84848a", typescript: "#1a85d4", + video: "#a631be", vite: "#a631be", vscode: "#1a85d4", vue: "#199f43", @@ -83,6 +84,7 @@ const customIcons = { pnpm: "t3-file-icon-pnpm", readme: "t3-file-icon-readme", tsconfig: "t3-file-icon-tsconfig", + video: "t3-file-icon-video", }; function symbolFromSprite(sprite, id) { diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.generated.ts b/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.generated.ts index 608fa08c486..463e00207d9 100644 --- a/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.generated.ts +++ b/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.generated.ts @@ -51,6 +51,7 @@ export const MARKDOWN_FILE_ICON_SOURCES = { text: require("../assets/file-icons/pierre_text.png"), tsconfig: require("../assets/file-icons/pierre_tsconfig.png"), typescript: require("../assets/file-icons/pierre_typescript.png"), + video: require("../assets/file-icons/pierre_video.png"), vite: require("../assets/file-icons/pierre_vite.png"), vscode: require("../assets/file-icons/pierre_vscode.png"), vue: require("../assets/file-icons/pierre_vue.png"), diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts index 20637c6ba0f..0caa24c3404 100644 --- a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts +++ b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts @@ -1,3 +1,9 @@ +import { + inlineCodeFilePathCandidate, + isConventionalFilePosition, +} from "@t3tools/client-runtime/markdown-links"; +import { videoMimeType } from "@t3tools/shared/video"; + import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated"; const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; @@ -253,15 +259,22 @@ function normalizeDestination(value: string): string { return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed; } +/** Native link and media APIs have no document scheme to inherit from protocol-relative URLs. */ +export function normalizeNativeMarkdownUrl(value: string): string { + return value.startsWith("//") ? `https:${value}` : value; +} + function fileUrlTarget(href: string): { readonly path: string; readonly hash: string } | null { try { const parsed = new URL(href); if (parsed.protocol.toLowerCase() !== "file:") { return null; } - const path = /^\/[A-Za-z]:[\\/]/.test(parsed.pathname) - ? parsed.pathname.slice(1) + const uncHostname = parsed.hostname.toLowerCase() === "localhost" ? "" : parsed.hostname; + const rawPath = uncHostname + ? `\\\\${uncHostname}${parsed.pathname.replaceAll("/", "\\")}` : parsed.pathname; + const path = /^\/[A-Za-z]:[\\/]/.test(rawPath) ? rawPath.slice(1) : rawPath; return { path, hash: parsed.hash }; } catch { return null; @@ -327,6 +340,7 @@ function looksLikeFilePath(value: string): boolean { if (FILE_ICON_BY_NAME[value.replace(POSITION_SUFFIX_PATTERN, "").toLowerCase()]) { return true; } + if (isConventionalFilePosition(value)) return true; return RELATIVE_FILE_PATH_PATTERN.test(value) || RELATIVE_FILE_NAME_PATTERN.test(value); } @@ -338,6 +352,7 @@ function fileLabel(value: string): string { export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon { const basename = fileLabel(value).replace(POSITION_SUFFIX_PATTERN, "").toLowerCase(); + if (videoMimeType({ name: basename, mimeType: "" }) !== null) return "video"; const exactIcon = FILE_ICON_BY_NAME[basename]; if (exactIcon) return exactIcon; if (basename.startsWith("tsconfig.") && basename.endsWith(".json")) { @@ -354,7 +369,7 @@ export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon { export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPresentation { const normalized = normalizeDestination(href); try { - const parsed = new URL(normalized); + const parsed = new URL(normalizeNativeMarkdownUrl(normalized)); if (parsed.protocol === "http:" || parsed.protocol === "https:") { return { kind: "external", @@ -399,3 +414,13 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese href: /^(?:mailto|tel):/i.test(normalized) ? normalized : null, }; } + +/** Backticks become file references only when the shared path heuristic recognizes the whole span. */ +export function resolveMarkdownInlineCodePresentation( + content: string, +): Extract | null { + const candidate = inlineCodeFilePathCandidate(content); + if (candidate === null) return null; + const presentation = resolveMarkdownLinkPresentation(candidate); + return presentation.kind === "file" ? presentation : null; +} diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts index 8db904b5a6c..2b39ac20159 100644 --- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts +++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts @@ -1,7 +1,11 @@ import type { MarkdownNode } from "react-native-nitro-markdown/headless"; import type { SelectableMarkdownSkill } from "./SelectableMarkdownText.types"; -import { resolveMarkdownLinkPresentation, type MarkdownFileIcon } from "./markdownLinks"; +import { + resolveMarkdownInlineCodePresentation, + resolveMarkdownLinkPresentation, + type MarkdownFileIcon, +} from "./markdownLinks"; export interface NativeMarkdownTextRun { readonly text: string; @@ -283,8 +287,17 @@ function appendNode( return appendRun(runs, textNodeContent(nodeTextContent(node)), context); case "html_inline": return appendRun(runs, inlineHtmlText(nodeTextContent(node)), context); - case "code_inline": - return appendRun(runs, nodeTextContent(node), { ...context, code: true }); + case "code_inline": { + const content = nodeTextContent(node); + const presentation = context.href ? null : resolveMarkdownInlineCodePresentation(content); + return presentation + ? appendRun(runs, presentation.label, { + ...context, + href: presentation.href, + fileIcon: presentation.icon, + }) + : appendRun(runs, content, { ...context, code: true }); + } case "soft_break": return appendRun(runs, " ", context); case "line_break": diff --git a/apps/mobile/src/components/FilePreview.ios.tsx b/apps/mobile/src/components/FilePreview.ios.tsx index c7740104fe6..c2f5a6d72cc 100644 --- a/apps/mobile/src/components/FilePreview.ios.tsx +++ b/apps/mobile/src/components/FilePreview.ios.tsx @@ -3,6 +3,7 @@ import { useEffect, useEffectEvent, useId } from "react"; import { Alert } from "react-native"; import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; +import { MediaImagePreview } from "./MediaImagePreview"; const NativeControls = requireNativeModule<{ presentFile( @@ -14,7 +15,7 @@ const NativeControls = requireNativeModule<{ dismissFile(identifier: string): Promise; }>("T3NativeControls"); -export function FilePreview(props: { +function NativeFilePreview(props: { readonly source: ResolvedFilePreviewSource; readonly onRequestClose: () => void; }) { @@ -41,3 +42,14 @@ export function FilePreview(props: { return null; } + +export function FilePreview(props: { + readonly source: ResolvedFilePreviewSource; + readonly onRequestClose: () => void; +}) { + return props.source.kind === "image" && props.source.actionsSource ? ( + + ) : ( + + ); +} diff --git a/apps/mobile/src/components/FilePreview.tsx b/apps/mobile/src/components/FilePreview.tsx index f10bfb8b3e6..8240c4a6ad3 100644 --- a/apps/mobile/src/components/FilePreview.tsx +++ b/apps/mobile/src/components/FilePreview.tsx @@ -4,6 +4,7 @@ import ImageViewing from "react-native-image-viewing"; import { downloadAndShareAttachment, shareLocalAttachment } from "../lib/attachmentDownload"; import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; +import { MediaImagePreview } from "./MediaImagePreview"; function PdfPreview(props: { readonly source: ResolvedFilePreviewSource; @@ -39,6 +40,7 @@ export function FilePreview(props: { readonly onRequestClose: () => void; }) { if (props.source.kind === "pdf") return ; + if (props.source.actionsSource) return ; return ( & @@ -40,13 +43,18 @@ function ResolvedFilePreview(props: { (connection._tag === "None" || asset._tag === "Failure"); useEffect(() => Keyboard.dismiss(), []); useEffect(() => { - if (uri === null && asset._tag === "Success") setUri(asset.url); - }, [uri, asset]); + if (uri === null && asset._tag === "Success") setUri(asset.url + (source.srcFragment ?? "")); + }, [uri, asset, source.srcFragment]); useEffect(() => { if (!failed) return; - Alert.alert("Could not open preview", "Reconnect to this environment and try again."); + Alert.alert( + "Could not open preview", + connection._tag === "None" + ? "Reconnect to this environment and try again." + : "The file could not be loaded. It may have been moved or deleted.", + ); onRequestClose(); - }, [failed]); + }, [failed, connection._tag]); useEffect(() => { if (!("attachment" in source)) return; const controller = new AbortController(); diff --git a/apps/mobile/src/components/MediaActionsMenu.tsx b/apps/mobile/src/components/MediaActionsMenu.tsx new file mode 100644 index 00000000000..a4b44e85258 --- /dev/null +++ b/apps/mobile/src/components/MediaActionsMenu.tsx @@ -0,0 +1,43 @@ +import { MenuView } from "@react-native-menu/menu"; +import type { ReactElement } from "react"; +import { Platform, View, type PressableProps } from "react-native"; + +import type { useMediaActions } from "../lib/mediaActions"; +import { SymbolView } from "./AppSymbol"; +import { ControlPillMenu } from "./ControlPill"; + +export function MediaActionsMenu(props: { + readonly media: ReturnType; + readonly inModal?: boolean; + readonly children?: ReactElement; +}) { + if (props.media.actions.length === 0) return props.children ?? null; + // Android's normal anchored menu lives in the app-root portal, behind native modals. + const nativeAndroidMenu = props.inModal && Platform.OS === "android"; + const Menu = nativeAndroidMenu ? MenuView : ControlPillMenu; + return ( + ({ + id, + title, + attributes: { disabled: disabled ?? false }, + }))} + onPressAction={({ nativeEvent }) => { + props.media.actions.find(({ id }) => id === nativeEvent.event)?.run(); + }} + > + {props.children ?? ( + + + + )} + + ); +} diff --git a/apps/mobile/src/components/MediaImagePreview.tsx b/apps/mobile/src/components/MediaImagePreview.tsx new file mode 100644 index 00000000000..5bdc9140ddc --- /dev/null +++ b/apps/mobile/src/components/MediaImagePreview.tsx @@ -0,0 +1,61 @@ +import { createContext, useContext } from "react"; +import { Pressable, View } from "react-native"; +import ImageViewing from "react-native-image-viewing"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { useMediaActions } from "../lib/mediaActions"; +import { AppText } from "./AppText"; +import { SymbolView } from "./AppSymbol"; +import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; +import { MediaActionsMenu } from "./MediaActionsMenu"; +import { MediaSourceCaption } from "./MediaSourceCaption"; + +type MediaImagePreviewProps = { + readonly source: ResolvedFilePreviewSource; + readonly onRequestClose: () => void; +}; + +const ImagePreviewContext = createContext(null); + +function ImagePreviewHeader() { + const props = useContext(ImagePreviewContext)!; + const insets = useSafeAreaInsets(); + const mediaActions = useMediaActions(props.source.actionsSource, props.onRequestClose); + return ( + + + + {props.source.name ?? "Image"} + + + + + + + + + ); +} + +/** Chat and workspace media retain source actions on both platforms; other files use native previews. */ +export function MediaImagePreview(props: MediaImagePreviewProps) { + return ( + + + + ); +} diff --git a/apps/mobile/src/components/MediaSourceCaption.tsx b/apps/mobile/src/components/MediaSourceCaption.tsx new file mode 100644 index 00000000000..76c24290d34 --- /dev/null +++ b/apps/mobile/src/components/MediaSourceCaption.tsx @@ -0,0 +1,19 @@ +import { ScrollView } from "react-native"; + +import { AppText } from "./AppText"; + +/** Keep the original reference readable without letting long URLs displace the preview. */ +export function MediaSourceCaption(props: { readonly source: string | undefined }) { + if (!props.source) return null; + return ( + + + {props.source} + + + ); +} diff --git a/apps/mobile/src/components/MediaVideoPlayer.tsx b/apps/mobile/src/components/MediaVideoPlayer.tsx new file mode 100644 index 00000000000..a065f75e139 --- /dev/null +++ b/apps/mobile/src/components/MediaVideoPlayer.tsx @@ -0,0 +1,187 @@ +import { useIsFocused } from "@react-navigation/native"; +import { useEvent } from "expo"; +import { useVideoPlayer, VideoView } from "expo-video"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; +import { ActivityIndicator, AppState, Pressable, View } from "react-native"; + +import { AppText } from "./AppText"; +import { SymbolView } from "./AppSymbol"; +import { VideoThumbnailImage } from "./VideoThumbnailImage"; +import { useMediaActions, type MediaActionsSource } from "../lib/mediaActions"; +import { MediaActionsMenu } from "./MediaActionsMenu"; + +/** Loads only after Play or opening the viewer. Source replacement never starts playback itself. */ +function LoadedMediaVideo(props: { + readonly uri: string; + readonly resolvePlaybackUri?: () => Promise; + readonly playRequested: boolean; + readonly paused: boolean; +}) { + const focused = useIsFocused(); + const active = useRef(focused && AppState.currentState === "active"); + const [attempt, setAttempt] = useState(0); + // Expo's Android player also reports completed playback as idle. + const [loadState, setLoadState] = useState<"pending" | "complete" | "error">("pending"); + const player = useVideoPlayer(null, (player) => { + player.staysActiveInBackground = false; + player.bufferOptions = { preferredForwardBufferDuration: 5 }; + }); + const { status } = useEvent(player, "statusChange", { status: player.status }); + const loadSource = useEffectEvent(async (signal: AbortSignal) => { + const uri = props.resolvePlaybackUri ? await props.resolvePlaybackUri() : props.uri; + if (signal.aborted) return; + if (uri === null) throw new Error("Video unavailable"); + player.pause(); + await player.replaceAsync({ uri, contentType: "progressive" }); + if (!signal.aborted && props.playRequested && active.current) player.play(); + }); + + useEffect(() => { + active.current = focused && !props.paused && AppState.currentState === "active"; + if (!active.current) player.pause(); + const subscription = AppState.addEventListener("change", (state) => { + active.current = focused && !props.paused && state === "active"; + if (!active.current) player.pause(); + }); + return () => subscription.remove(); + }, [focused, player, props.paused]); + + useEffect(() => { + const controller = new AbortController(); + setLoadState("pending"); + // A renewed signature is used on Retry, not as a reason to reset the native player. + void loadSource(controller.signal).then( + () => { + if (!controller.signal.aborted) setLoadState("complete"); + }, + () => { + if (!controller.signal.aborted) setLoadState("error"); + }, + ); + return () => controller.abort(); + }, [player, props.playRequested, attempt]); + + return ( + + + {loadState === "error" || (loadState === "complete" && status === "error") ? ( + + Video unavailable + setAttempt((value) => value + 1)} + className="min-h-11 justify-center px-4" + > + Retry + + + ) : loadState === "pending" || status === "loading" ? ( + + + + ) : null} + + ); +} + +interface MediaVideoPlayerProps { + readonly uri: string | null; + readonly resolvePlaybackUri?: () => Promise; + readonly name: string; + readonly thumbnailKey: string; + readonly thumbnailVisible?: boolean; + readonly unavailable?: boolean; + readonly expanded?: boolean; + readonly paused?: boolean; + readonly onExpand?: () => void; + readonly actionsSource?: MediaActionsSource; +} + +function MediaVideoPlayerContent(props: MediaVideoPlayerProps) { + const mediaActions = useMediaActions(props.actionsSource); + const [playbackUri, setPlaybackUri] = useState(props.expanded ? props.uri : null); + // Keep an opened player mounted while signing or reconnecting temporarily has no usable URL. + if (playbackUri === null && props.expanded && props.uri !== null) setPlaybackUri(props.uri); + + return ( + + {playbackUri ? ( + + ) : ( + setPlaybackUri(props.uri)} + className="flex-1 items-center justify-center gap-2 px-4" + > + {!props.unavailable ? ( + + ) : null} + {props.unavailable ? ( + Video unavailable + ) : props.uri === null ? ( + + ) : ( + <> + + + + + {props.name} + + + )} + + )} + {props.onExpand ? ( + { + setPlaybackUri(null); + props.onExpand?.(); + }} + className="absolute right-1 top-1 min-h-11 min-w-11 items-center justify-center rounded-md bg-black/60 px-2" + > + Expand + + ) : null} + {props.actionsSource ? ( + + + + ) : null} + + ); +} + +export function MediaVideoPlayer(props: MediaVideoPlayerProps) { + return ; +} diff --git a/apps/mobile/src/components/MediaVideoPreviewModal.tsx b/apps/mobile/src/components/MediaVideoPreviewModal.tsx new file mode 100644 index 00000000000..6c231194701 --- /dev/null +++ b/apps/mobile/src/components/MediaVideoPreviewModal.tsx @@ -0,0 +1,96 @@ +import { useEffect } from "react"; +import { Keyboard, Modal, Pressable, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { useMediaActions } from "../lib/mediaActions"; +import { MediaActionsMenu } from "./MediaActionsMenu"; +import { + mediaVideoPreviewUri, + mediaVideoThumbnailKey, + type MediaVideoPreviewSource, +} from "../lib/videoPreviewSource"; +import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets"; +import { usePreparedConnection } from "../state/session"; +import { AppText } from "./AppText"; +import { SymbolView } from "./AppSymbol"; +import { MediaVideoPlayer } from "./MediaVideoPlayer"; +import { MediaSourceCaption } from "./MediaSourceCaption"; + +/** Media files stream in place. A client-side copy is made only for an explicit share. */ +export function MediaVideoPreviewModal(props: { + readonly source: MediaVideoPreviewSource; + readonly onRequestClose: () => void; +}) { + const { source } = props; + const insets = useSafeAreaInsets(); + const environmentId = "environmentId" in source ? source.environmentId : null; + const connection = usePreparedConnection(environmentId); + const asset = useAssetUrlState(environmentId, "resource" in source ? source.resource : null); + const refreshAssetUrl = useRefreshAssetUrl( + environmentId, + "resource" in source ? source.resource : null, + ); + const resolvePlaybackUri = + "resource" in source + ? async () => mediaVideoPreviewUri(source, await refreshAssetUrl()) + : undefined; + const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null); + const mediaActions = useMediaActions(source.actionsSource, props.onRequestClose); + const unavailable = + uri === null && + environmentId !== null && + (connection._tag === "None" || asset._tag === "Failure"); + + useEffect(() => Keyboard.dismiss(), []); + return ( + + + + + {source.name} + + + + + + + + + + + {mediaActions.sharing ? "Opening share sheet..." : "Save or share video"} + + + + + ); +} diff --git a/apps/mobile/src/components/VideoPreviewModal.ios.tsx b/apps/mobile/src/components/VideoPreviewModal.ios.tsx index a947d8d2e51..88a1b5191dd 100644 --- a/apps/mobile/src/components/VideoPreviewModal.ios.tsx +++ b/apps/mobile/src/components/VideoPreviewModal.ios.tsx @@ -7,9 +7,10 @@ import { Alert, Keyboard } from "react-native"; import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; import { useAssetUrlState } from "../state/assets"; import { usePreparedConnection } from "../state/session"; -import type { VideoPreviewSource } from "./VideoPreviewModal"; +import type { AttachmentVideoPreviewSource, VideoPreviewSource } from "../lib/videoPreviewSource"; +import { MediaVideoPreviewModal } from "./MediaVideoPreviewModal"; -export type { VideoPreviewSource } from "./VideoPreviewModal"; +export type { VideoPreviewSource } from "../lib/videoPreviewSource"; const NativeControls = requireNativeModule<{ presentVideo( @@ -22,7 +23,7 @@ const NativeControls = requireNativeModule<{ }>("T3NativeControls"); function NativeVideoPreview(props: { - readonly source: VideoPreviewSource; + readonly source: AttachmentVideoPreviewSource; readonly onRequestClose: () => void; }) { const { source } = props; @@ -117,5 +118,8 @@ export function VideoPreviewModal(props: { }, [isFocused, hasSource]); if (!props.source || !isFocused) return null; + if (props.source.type === "media") { + return ; + } return ; } diff --git a/apps/mobile/src/components/VideoPreviewModal.tsx b/apps/mobile/src/components/VideoPreviewModal.tsx index eaa01c5d171..cc56b3952b7 100644 --- a/apps/mobile/src/components/VideoPreviewModal.tsx +++ b/apps/mobile/src/components/VideoPreviewModal.tsx @@ -1,5 +1,4 @@ import { useIsFocused } from "@react-navigation/native"; -import type { ChatFileAttachment, EnvironmentId } from "@t3tools/contracts"; import { videoMimeType } from "@t3tools/shared/video"; import { useEvent } from "expo"; import { useVideoPlayer, VideoView } from "expo-video"; @@ -19,21 +18,15 @@ import { downloadAttachmentForPreview, type AttachmentPreviewFile, } from "../lib/attachmentDownload"; -import type { DraftComposerFileAttachment } from "../lib/composerImages"; import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; +import type { AttachmentVideoPreviewSource, VideoPreviewSource } from "../lib/videoPreviewSource"; import { useAssetUrlState } from "../state/assets"; import { usePreparedConnection } from "../state/session"; import { SymbolView } from "./AppSymbol"; import { AppText } from "./AppText"; +import { MediaVideoPreviewModal } from "./MediaVideoPreviewModal"; -export type VideoPreviewSource = ( - | { readonly type: "local"; readonly attachment: DraftComposerFileAttachment } - | { - readonly type: "remote"; - readonly environmentId: EnvironmentId; - readonly attachment: ChatFileAttachment; - } -) & { readonly sourceIdentifier?: string }; +export type { VideoPreviewSource } from "../lib/videoPreviewSource"; function VideoPlayback(props: { readonly file: AttachmentPreviewFile }) { const player = useVideoPlayer(props.file.uri, (player) => { @@ -120,7 +113,7 @@ function VideoPlayback(props: { readonly file: AttachmentPreviewFile }) { } function OpenVideoPreviewModal(props: { - readonly source: VideoPreviewSource; + readonly source: AttachmentVideoPreviewSource; readonly onRequestClose: () => void; }) { const { source } = props; @@ -250,6 +243,9 @@ export function VideoPreviewModal(props: { }, [isFocused, hasSource, props.onRequestClose]); const { source } = props; if (source === null || !isFocused) return null; + if (source.type === "media") { + return ; + } const key = source.type === "local" ? `local:${source.attachment.id}:${source.attachment.fileUri}` diff --git a/apps/mobile/src/components/VideoThumbnailImage.tsx b/apps/mobile/src/components/VideoThumbnailImage.tsx index 0be94c700ce..cfb8ceeb2ac 100644 --- a/apps/mobile/src/components/VideoThumbnailImage.tsx +++ b/apps/mobile/src/components/VideoThumbnailImage.tsx @@ -11,6 +11,7 @@ import { cachedVideoThumbnail, loadVideoThumbnail } from "../lib/videoThumbnails export function VideoThumbnailImage(props: { readonly cacheKey: string; readonly source: string | DraftComposerFileAttachment | null; + readonly contentFit?: "cover" | "contain"; }) { const { cacheKey, source } = props; const isFocused = useIsFocused(); @@ -37,7 +38,7 @@ export function VideoThumbnailImage(props: { diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 5dddac1dd82..052c89a7e62 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -1,7 +1,7 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import type { MenuAction } from "@react-native-menu/menu"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import { ActivityIndicator, Platform, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; @@ -11,6 +11,9 @@ import { type ProjectReadFileResult, ThreadId, } from "@t3tools/contracts"; +import { videoMimeType } from "@t3tools/shared/video"; +import { mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; +import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { SymbolView } from "../../components/AppSymbol"; @@ -24,6 +27,8 @@ import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { isPdfFile } from "../../lib/filePreview"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; +import type { MediaVideoPreviewSource } from "../../lib/videoPreviewSource"; +import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions"; import { useThreadSelection } from "../../state/use-thread-selection"; import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; import { useEnvironmentQuery } from "../../state/query"; @@ -47,6 +52,7 @@ import { preloadWorkspaceFileContents } from "./preload-workspace-file"; import { SourceFileSurface } from "./SourceFileSurface"; import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane"; import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview"; +import { WorkspaceFileVideoPreview } from "./WorkspaceFileVideoPreview"; import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; import { basename, @@ -54,8 +60,9 @@ import { isImagePreviewFile, isMarkdownPreviewFile, isSvgImagePreviewFile, + isVideoPreviewFile, } from "./filePath"; -import { useWorkspaceFileAssetUrl } from "./workspaceFileAssetUrl"; +import { useWorkspaceFileAssetUrlState } from "./workspaceFileAssetUrl"; type FileViewMode = "preview" | "source"; @@ -84,7 +91,8 @@ function normalizeRouteLine(value: string | null): number | null { } function defaultViewMode(path: string | null): FileViewMode { - return path !== null && (isBrowserPreviewFile(path) || isImagePreviewFile(path)) + return path !== null && + (isBrowserPreviewFile(path) || isImagePreviewFile(path) || isVideoPreviewFile(path)) ? "preview" : "source"; } @@ -92,6 +100,10 @@ function defaultViewMode(path: string | null): FileViewMode { function FileContent(props: { readonly activeMode: FileViewMode; readonly previewUri: string | null; + readonly previewUnavailable: boolean; + readonly videoSource: MediaVideoPreviewSource | null; + readonly mediaSource?: MediaActionsSource; + readonly resolveVideoUri: () => Promise; readonly fileContents: string | null; readonly fileError: string | null; readonly relativePath: string; @@ -99,10 +111,25 @@ function FileContent(props: { readonly truncated: boolean; readonly onRefresh?: () => Promise | void; }) { + // Reopening a mutable host file must not reuse a poster from an earlier visit. + const thumbnailInstanceId = useId(); const isMarkdown = isMarkdownPreviewFile(props.relativePath); const isBrowserFile = isBrowserPreviewFile(props.relativePath); const isImageFile = isImagePreviewFile(props.relativePath); + if (isVideoPreviewFile(props.relativePath)) { + return ( + + ); + } + if (props.activeMode === "preview" && isImageFile) { if (isSvgImagePreviewFile(props.relativePath)) { return ; @@ -111,6 +138,7 @@ function FileContent(props: { ); } @@ -489,29 +517,73 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { readonly mode: FileViewMode; } | null>(null); const [previewRevision, setPreviewRevision] = useState(0); + const previewKey = JSON.stringify([environmentId, cwd, relativePath, previewRevision]); const [fullScreenPreview, setFullScreenPreview] = useState(null); - const isBrowserFile = relativePath !== null && isBrowserPreviewFile(relativePath); - const isImageFile = relativePath !== null && isImagePreviewFile(relativePath); + const isVideoFile = relativePath !== null && isVideoPreviewFile(relativePath); + const isBrowserFile = relativePath !== null && !isVideoFile && isBrowserPreviewFile(relativePath); + const isImageFile = relativePath !== null && !isVideoFile && isImagePreviewFile(relativePath); const canPreview = - relativePath !== null && (isMarkdownPreviewFile(relativePath) || isBrowserFile || isImageFile); + relativePath !== null && + (isMarkdownPreviewFile(relativePath) || isBrowserFile || isImageFile || isVideoFile); const activeMode = relativePath !== null && modeOverride?.path === relativePath ? modeOverride.mode : defaultViewMode(relativePath); - const resolvedActiveMode = canPreview ? activeMode : "source"; - const assetPreviewPath = isBrowserFile || isImageFile ? relativePath : null; - const assetPreviewUri = useWorkspaceFileAssetUrl({ + const resolvedActiveMode = isVideoFile ? "preview" : canPreview ? activeMode : "source"; + const assetPreviewPath = isBrowserFile || isImageFile || isVideoFile ? relativePath : null; + const assetPreview = useWorkspaceFileAssetUrlState({ cwd, environmentId, relativePath: assetPreviewPath, threadId, }); + const assetPreviewUri = assetPreview._tag === "Success" ? assetPreview.url : null; + const mediaSource = useMemo( + () => + environmentId !== null && + threadId !== null && + relativePath !== null && + assetPreview.resource !== null && + "path" in assetPreview.resource && + typeof assetPreview.resource.path === "string" && + (isImageFile || isVideoFile) + ? { + reference: mediaFileReference(assetPreview.resource.path, cwd), + name: basename(relativePath), + mimeType: + mediaMimeTypeFromExtension(relativePath.slice(relativePath.lastIndexOf("."))) ?? + "application/octet-stream", + environmentId, + threadId, + resource: assetPreview.resource, + } + : undefined, + [assetPreview.resource, cwd, environmentId, isImageFile, isVideoFile, relativePath, threadId], + ); + const mediaActions = useMediaActions(mediaSource); + const videoSource = useMemo( + () => + environmentId !== null && + relativePath !== null && + assetPreview.resource?._tag === "media-file" + ? { + type: "media", + environmentId, + resource: assetPreview.resource, + name: basename(relativePath), + mimeType: videoMimeType({ name: relativePath, mimeType: "" }) ?? "video/mp4", + actionsSource: mediaSource, + } + : null, + [assetPreview.resource, environmentId, relativePath, mediaSource], + ); const previewUri = assetPreviewUri === null || previewRevision === 0 ? assetPreviewUri : `${assetPreviewUri}${assetPreviewUri.includes("?") ? "&" : "?"}revision=${previewRevision}`; const needsFileContents = relativePath !== null && + !isVideoFile && (resolvedActiveMode === "source" || isMarkdownPreviewFile(relativePath)); const fileQuery = useEnvironmentQuery( environmentId !== null && cwd !== null && relativePath !== null && needsFileContents @@ -562,7 +634,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { const fileMenuActions = useMemo(() => { if (relativePath === null) return []; - const canToggleMode = canPreview && !isImageFile; + const canToggleMode = canPreview && !isImageFile && !isVideoFile; return [ canToggleMode ? ({ @@ -582,13 +654,26 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { onPress: () => setModeOverride({ path: relativePath, mode: "source" }), } as const) : null, - { - id: "copy-path", - title: "Copy path", - icon: "doc.on.doc", - inline: false, - onPress: () => copyTextWithHaptic(relativePath), - } as const, + ...(mediaSource + ? mediaActions.actions + .filter(({ id }) => id !== "open-file") + .map((action) => ({ + id: action.id, + title: action.title, + icon: + action.id === "share" ? ("square.and.arrow.up" as const) : ("doc.on.doc" as const), + inline: false, + onPress: action.run, + })) + : [ + { + id: "copy-path", + title: "Copy path", + icon: "doc.on.doc", + inline: false, + onPress: () => copyTextWithHaptic(relativePath), + } as const, + ]), isPdfFile({ name: relativePath }) && previewUri !== null ? ({ id: "open-pdf", @@ -612,24 +697,31 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { onPress: () => tryOpenExternalUrl(assetPreviewUri, "file-preview"), } as const) : null, - resolvedActiveMode === "preview" && (isBrowserFile || isImageFile) + resolvedActiveMode === "preview" && (isBrowserFile || isImageFile || isVideoFile) ? ({ id: "refresh", title: "Refresh", icon: "arrow.clockwise", inline: false, - onPress: () => setPreviewRevision((current) => current + 1), + onPress: async () => { + if (isVideoFile) await assetPreview.refresh(); + setPreviewRevision((current) => current + 1); + }, } as const) : null, ].filter((action) => action !== null); }, [ assetPreviewUri, + assetPreview.refresh, previewUri, canPreview, isBrowserFile, isImageFile, + isVideoFile, relativePath, resolvedActiveMode, + mediaSource, + mediaActions.actions, ]); const androidFileMenuActions = useMemo( @@ -782,8 +874,13 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { (null); const [preview, setPreview] = useState(null); const sourceIdentifier = useId(); + const mediaActions = useMediaActions(props.actionsSource); const imageSource = useMemo( () => ({ uri: props.uri, cache: "force-cache" as const }), [props.uri], @@ -34,6 +38,7 @@ function ResolvedWorkspaceFileImagePreview(props: { uri: props.uri, name: props.accessibilityLabel, sourceIdentifier, + actionsSource: props.actionsSource, }) } > @@ -50,13 +55,16 @@ function ResolvedWorkspaceFileImagePreview(props: { /> - {loadError !== null ? ( ) : null} + + + + setPreview(null)} /> ); @@ -65,6 +73,7 @@ function ResolvedWorkspaceFileImagePreview(props: { function CachedWorkspaceFileImagePreview(props: { readonly accessibilityLabel: string; readonly uri: string; + readonly actionsSource?: MediaActionsSource; }) { const imageAtom = useMemo(() => workspaceFileImageAtom(props.uri), [props.uri]); const imageResult = useAtomValue(imageAtom); @@ -93,6 +102,7 @@ function CachedWorkspaceFileImagePreview(props: { ); } @@ -100,6 +110,7 @@ function CachedWorkspaceFileImagePreview(props: { export function WorkspaceFileImagePreview(props: { readonly accessibilityLabel: string; readonly uri: string | null; + readonly actionsSource?: MediaActionsSource; }) { if (props.uri === null) { return ( @@ -116,6 +127,7 @@ export function WorkspaceFileImagePreview(props: { ); } diff --git a/apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx b/apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx new file mode 100644 index 00000000000..aaa13427fac --- /dev/null +++ b/apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx @@ -0,0 +1,47 @@ +import { useState } from "react"; +import { View } from "react-native"; + +import { EmptyState } from "../../components/EmptyState"; +import { MediaVideoPlayer } from "../../components/MediaVideoPlayer"; +import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; +import type { MediaVideoPreviewSource } from "../../lib/videoPreviewSource"; + +/** Uses the signed progressive URL directly; choosing a file never preloads its video bytes as text. */ +export function WorkspaceFileVideoPreview(props: { + readonly name: string; + readonly thumbnailKey: string; + readonly uri: string | null; + readonly source: MediaVideoPreviewSource | null; + readonly resolvePlaybackUri: () => Promise; + readonly unavailable: boolean; +}) { + const [preview, setPreview] = useState(null); + const uri = props.uri; + + if (props.unavailable) { + return ( + + + + ); + } + + return ( + + setPreview(props.source) + } + /> + setPreview(null)} /> + + ); +} diff --git a/apps/mobile/src/features/files/filePath.ts b/apps/mobile/src/features/files/filePath.ts index 385d5c139ee..12217aab74f 100644 --- a/apps/mobile/src/features/files/filePath.ts +++ b/apps/mobile/src/features/files/filePath.ts @@ -1,6 +1,7 @@ import { isWorkspaceBrowserPreviewPath, isWorkspaceImagePreviewPath, + isWorkspaceVideoPreviewPath, } from "@t3tools/shared/filePreview"; export interface FileBreadcrumb { @@ -95,6 +96,10 @@ export function isImagePreviewFile(path: string): boolean { return isWorkspaceImagePreviewPath(path); } +export function isVideoPreviewFile(path: string): boolean { + return isWorkspaceVideoPreviewPath(path); +} + export function isSvgImagePreviewFile(path: string): boolean { return /\.svg$/i.test(path.split(/[?#]/, 1)[0] ?? ""); } diff --git a/apps/mobile/src/features/files/preload-workspace-file.ts b/apps/mobile/src/features/files/preload-workspace-file.ts index b9e21cfd98f..7df750883f5 100644 --- a/apps/mobile/src/features/files/preload-workspace-file.ts +++ b/apps/mobile/src/features/files/preload-workspace-file.ts @@ -3,7 +3,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { appAtomRegistry } from "../../state/atom-registry"; import { projectEnvironment } from "../../state/projects"; -import { isBrowserPreviewFile, isImagePreviewFile } from "./filePath"; +import { isBrowserPreviewFile, isImagePreviewFile, isVideoPreviewFile } from "./filePath"; import { prepareSourceFileDocument } from "./source-file-document"; import { sourceHighlightAtom } from "./sourceHighlightingState"; import type { ReviewDiffTheme } from "../review/shikiReviewHighlighter"; @@ -25,7 +25,11 @@ export function preloadWorkspaceFileContents(input: { readonly relativePath: string; readonly theme: ReviewDiffTheme; }): void { - if (isBrowserPreviewFile(input.relativePath) || isImagePreviewFile(input.relativePath)) { + if ( + isBrowserPreviewFile(input.relativePath) || + isImagePreviewFile(input.relativePath) || + isVideoPreviewFile(input.relativePath) + ) { return; } diff --git a/apps/mobile/src/features/files/workspaceFileAssetUrl.ts b/apps/mobile/src/features/files/workspaceFileAssetUrl.ts index 70ea3e43582..eb2ba93b497 100644 --- a/apps/mobile/src/features/files/workspaceFileAssetUrl.ts +++ b/apps/mobile/src/features/files/workspaceFileAssetUrl.ts @@ -1,10 +1,10 @@ -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { AssetResource, EnvironmentId, ThreadId } from "@t3tools/contracts"; import { useMemo } from "react"; -import { useAssetUrl } from "../../state/assets"; -import { resolveWorkspaceFilePath } from "./filePath"; +import { useAssetUrlState, useRefreshAssetUrl } from "../../state/assets"; +import { isVideoPreviewFile, resolveWorkspaceFilePath } from "./filePath"; -export function useWorkspaceFileAssetUrl(props: { +export function useWorkspaceFileAssetUrlState(props: { readonly cwd: string | null; readonly environmentId: EnvironmentId | null; readonly relativePath: string | null; @@ -18,14 +18,18 @@ export function useWorkspaceFileAssetUrl(props: { [props.cwd, props.relativePath], ); - return useAssetUrl( - props.environmentId, - absolutePath !== null && props.threadId !== null - ? { - _tag: "workspace-file", - threadId: props.threadId, - path: absolutePath, - } - : null, + const resource = useMemo( + () => + absolutePath !== null && props.threadId !== null + ? { + _tag: isVideoPreviewFile(absolutePath) ? "media-file" : "workspace-file", + threadId: props.threadId, + path: absolutePath, + } + : null, + [absolutePath, props.threadId], ); + const state = useAssetUrlState(props.environmentId, resource); + const refresh = useRefreshAssetUrl(props.environmentId, resource); + return { ...state, resource, refresh }; } diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index cb72aa1f653..3cbf02efa2b 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1,6 +1,6 @@ import * as Haptics from "expo-haptics"; import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; -import { type LegendListRef } from "@legendapp/list/react-native"; +import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native"; import type { AssetResource, ChatAttachment, @@ -33,6 +33,7 @@ import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; import { HeaderHeightContext } from "@react-navigation/elements"; import { useFocusEffect, useNavigation } from "@react-navigation/native"; import { + createContext, memo, useCallback, useContext, @@ -97,6 +98,15 @@ import { import { AppText as Text } from "../../components/AppText"; import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; import { VideoAttachmentTile } from "../../components/VideoAttachmentTile"; +import { MediaVideoPlayer } from "../../components/MediaVideoPlayer"; +import { resolveMarkdownMediaPreview } from "../../lib/markdownMedia"; +import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions"; +import { MediaActionsMenu } from "../../components/MediaActionsMenu"; +import { + mediaVideoPreviewUri, + mediaVideoThumbnailKey, + type MediaVideoPreviewSource, +} from "../../lib/videoPreviewSource"; import { CopyTextButton } from "../../components/CopyTextButton"; import { parseReviewCommentMessageSegments, @@ -123,7 +133,11 @@ import { import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; -import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; +import { + normalizeNativeMarkdownUrl, + resolveMarkdownInlineCodePresentation, + resolveMarkdownLinkPresentation, +} from "@t3tools/mobile-markdown-text/links"; import { deriveThreadFeedPresentation, type ThreadFeedEntry, @@ -143,7 +157,12 @@ import { WORK_GROUP_TOGGLE_HEIGHT, } from "./thread-work-log"; import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; -import { assetEnvironment, useAssetUrl, useAssetUrlState } from "../../state/assets"; +import { + assetEnvironment, + useAssetUrl, + useAssetUrlState, + useRefreshAssetUrl, +} from "../../state/assets"; import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; import { usePreparedConnection } from "../../state/session"; import * as Option from "effect/Option"; @@ -272,6 +291,7 @@ function MessageAttachmentFile(props: { }) { const sourceIdentifier = useId(); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { + refresh: true, reportFailure: false, }); const preparedConnection = usePreparedConnection(props.environmentId); @@ -453,9 +473,11 @@ function ThreadMarkdownImageView(props: { readonly sourceKey: string; readonly unavailable: boolean; readonly alt: string | null; + readonly actionsSource?: MediaActionsSource; readonly onPressPreview: (source: FilePreviewSource) => void; }) { const sourceIdentifier = useId(); + const mediaActions = useMediaActions(props.actionsSource); const [availableWidth, setAvailableWidth] = useState(0); const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null); const [failedUri, setFailedUri] = useState(null); @@ -498,36 +520,51 @@ function ThreadMarkdownImageView(props: { ) : ( )} + {props.actionsSource ? ( + + + + ) : null} ) : ( - - props.onPressPreview({ - kind: "image", - uri: props.uri!, - name: props.alt ?? "Image", - sourceIdentifier, - }) - } - style={{ alignSelf: "flex-start" }} - > - - setFailedUri(props.uri)} - /> - - + + + + props.onPressPreview({ + kind: "image", + uri: props.uri!, + name: props.alt ?? "Image", + sourceIdentifier, + actionsSource: props.actionsSource, + }) + } + style={{ alignSelf: "flex-start" }} + > + + setFailedUri(props.uri)} + /> + + + + {props.actionsSource ? ( + + + + ) : null} + )} {props.alt ? ( @@ -574,9 +611,10 @@ function ThreadMarkdownImageRequest(props: { /** Environment-hosted image that loads through a signed asset URL. */ function ThreadMarkdownImage(props: { readonly environmentId: EnvironmentId; - readonly resource: Extract; + readonly resource: Extract; readonly alt: string | null; readonly srcFragment?: string; + readonly actionsSource?: MediaActionsSource; readonly onPressPreview: (source: FilePreviewSource) => void; }) { const assetUrl = useAssetUrlState(props.environmentId, props.resource); @@ -591,11 +629,59 @@ function ThreadMarkdownImage(props: { } unavailable={assetUrl._tag === "Failure"} alt={props.alt} + actionsSource={props.actionsSource} onPressPreview={props.onPressPreview} /> ); } +const ThreadMediaVisibleContext = createContext(false); +// LegendList only computes hook visibility when the list has a viewability config. +const THREAD_MEDIA_VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 0 }; + +function ThreadMediaVisibility(props: { readonly children: ReactNode }) { + const [visible, setVisible] = useState(false); + useViewabilityAmount( + useCallback((token) => setVisible(token.sizeVisible > 0), []), + ); + return {props.children}; +} + +function ThreadMarkdownVideo(props: { + readonly source: MediaVideoPreviewSource; + readonly onExpand: (source: MediaVideoPreviewSource) => void; +}) { + const { source } = props; + const visible = useContext(ThreadMediaVisibleContext); + const thumbnailKey = mediaVideoThumbnailKey(source); + const asset = useAssetUrlState( + "environmentId" in source ? source.environmentId : null, + "resource" in source ? source.resource : null, + ); + const refreshAssetUrl = useRefreshAssetUrl( + "environmentId" in source ? source.environmentId : null, + "resource" in source ? source.resource : null, + ); + const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null); + return ( + mediaVideoPreviewUri(source, await refreshAssetUrl()) + : undefined + } + name={source.name} + thumbnailKey={thumbnailKey} + thumbnailVisible={visible} + unavailable={"resource" in source && asset._tag === "Failure"} + actionsSource={source.actionsSource} + onExpand={() => props.onExpand(source)} + /> + ); +} + function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) { return ( (); +const MarkdownLinkLabelContext = createContext(false); const markdownLinkStyles = StyleSheet.create({ inlineIcon: { width: 14, @@ -653,15 +740,14 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { readonly color: string; readonly host: string; readonly href: string; + readonly onPress: (href: string) => void; }) { const [failed, setFailed] = useState(() => failedMarkdownFaviconHosts.has(props.host)); return ( { - void tryOpenExternalUrl(props.href, "markdown-link"); - }} + onPress={() => props.onPress(props.href)} style={{ color: props.color, textDecorationLine: "none", @@ -686,6 +772,37 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { ); }); +function MarkdownInlineCode(props: { + readonly content: string; + readonly textColor: string; + readonly codeColor: string; + readonly fontSize: number; + readonly lineHeight: number; + readonly onLinkPress: (href: string) => void; +}) { + const insideLink = useContext(MarkdownLinkLabelContext); + const presentation = insideLink ? null : resolveMarkdownInlineCodePresentation(props.content); + return ( + props.onLinkPress(presentation.href) : undefined} + style={{ + color: presentation ? props.textColor : props.codeColor, + fontSize: props.fontSize, + lineHeight: props.lineHeight, + }} + > + {presentation ? ( + + ) : null} + {presentation?.label ?? props.content} + + ); +} + const ARTIFACT_TEMPLATE_SYMBOL_BY_KIND: Record< CodexArtifactTemplate["artifactKind"], AppSymbolName @@ -1078,30 +1195,35 @@ function useMarkdownStyles( } if (presentation.kind === "external") { return ( - - {children} - + + + {children} + + ); } const linkHref = presentation.href; return ( - { - void tryOpenExternalUrl(linkHref, "markdown-link"); - } - : undefined - } - style={{ color: markdownLinkColor }} - > - {children} - + + { + void tryOpenExternalUrl(linkHref, "markdown-link"); + } + : undefined + } + style={{ color: markdownLinkColor }} + > + {children} + + ); }, list: ({ node, Renderer, ordered = false, start = 1 }) => ( @@ -1144,21 +1266,16 @@ function useMarkdownStyles( title: node.title ?? null, }) ?? undefined) : undefined, - code_inline: ({ content }) => { - const value = content ?? ""; - return ( - - {value} - - ); - }, + code_inline: ({ content }) => ( + + ), ...(preserveSoftBreaks ? { soft_break: () => {"\n"}, @@ -1954,11 +2071,26 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { path: relativePath.split("/").filter((segment) => segment.length > 0), ...(presentation.line ? { line: String(presentation.line) } : {}), }); + return; + } + } + + const media = resolveMarkdownMediaPreview(href, { + environmentId: props.environmentId, + threadId: props.threadId, + workspaceRoot: props.workspaceRoot, + }); + if (media) { + void Haptics.selectionAsync(); + if (media.kind === "video") { + setExpandedVideo((current) => current ?? media.source); + } else { + setExpandedFile((current) => current ?? media.source); } return; } - if (presentation.href) { + if (presentation.kind !== "file" && presentation.href) { if (/^https?:\/\//i.test(presentation.href) && isPdfFile({ name: presentation.href })) { setExpandedFile( (current) => current ?? { kind: "pdf", uri: presentation.href!, name: "Document.pdf" }, @@ -1972,14 +2104,30 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ); const renderMarkdownImage = useCallback( (image) => { + const media = resolveMarkdownMediaPreview(image.href, { + environmentId: props.environmentId, + threadId: props.threadId, + workspaceRoot: props.workspaceRoot, + imageEmbed: true, + }); + if (media?.kind === "video") { + return ( + setExpandedVideo((current) => current ?? source)} + /> + ); + } const imageSource = classifyMarkdownImageSource(image.href, props.workspaceRoot ?? null); if (imageSource._tag === "Direct") { return ( setExpandedFile((current) => current ?? source)} /> ); @@ -1991,12 +2139,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { setExpandedFile((current) => current ?? source)} /> ); @@ -2009,12 +2158,26 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { threadId: props.threadId, workspaceRoot: props.workspaceRoot, }); + const media = viewedImage + ? resolveMarkdownMediaPreview(image.href, { + environmentId: props.environmentId, + threadId: props.threadId, + workspaceRoot: props.workspaceRoot, + imageEmbed: true, + }) + : null; + const actionsSource = media?.source.actionsSource; return viewedImage ? ( setExpandedFile((current) => current ?? source)} /> ) : null; @@ -2438,30 +2601,32 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { key={info.item.id} entering={disclosureToggleSettling ? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION : undefined} > - {renderFeedEntry(info, { - environmentId: props.environmentId, - copiedRowId, - expandedWorkRows, - terminalAssistantMessageIds, - unsettledTurnId, - onCopyWorkRow, - onToggleWorkGroup, - onToggleWorkRow, - onToggleTurnFold, - onPressPreview, - onPressVideo, - onMarkdownLinkPress, - renderMarkdownImage, - renderViewedImage, - iconSubtleColor, - userBubbleColor, - markdownStyles, - reviewCommentColors, - reviewCommentBubbleWidth, - userBubbleMaxWidth, - skills: props.skills, - onUseArtifactTemplate: props.onUseArtifactTemplate, - })} + + {renderFeedEntry(info, { + environmentId: props.environmentId, + copiedRowId, + expandedWorkRows, + terminalAssistantMessageIds, + unsettledTurnId, + onCopyWorkRow, + onToggleWorkGroup, + onToggleWorkRow, + onToggleTurnFold, + onPressPreview, + onPressVideo, + onMarkdownLinkPress, + renderMarkdownImage, + renderViewedImage, + iconSubtleColor, + userBubbleColor, + markdownStyles, + reviewCommentColors, + reviewCommentBubbleWidth, + userBubbleMaxWidth, + skills: props.skills, + onUseArtifactTemplate: props.onUseArtifactTemplate, + })} + ), [ @@ -2586,6 +2751,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { data={presentedFeed} extraData={listAppearanceData} renderItem={renderItem} + viewabilityConfig={THREAD_MEDIA_VIEWABILITY_CONFIG} keyExtractor={(entry) => entry.id} getItemType={(entry) => entry.type === "message" ? `message:${entry.message.role}` : entry.type diff --git a/apps/mobile/src/lib/markdownLinks.test.ts b/apps/mobile/src/lib/markdownLinks.test.ts index 49a8b46648e..bf3d009b74a 100644 --- a/apps/mobile/src/lib/markdownLinks.test.ts +++ b/apps/mobile/src/lib/markdownLinks.test.ts @@ -3,6 +3,22 @@ import { describe, expect, it } from "vite-plus/test"; import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; describe("resolveMarkdownLinkPresentation", () => { + it("treats protocol-relative media as an external URL, not a filesystem path", () => { + expect(resolveMarkdownLinkPresentation("//cdn.example.com/clip.mp4?sig=a%2fb#t=2")).toEqual({ + kind: "external", + href: "https://cdn.example.com/clip.mp4?sig=a%2fb#t=2", + host: "cdn.example.com", + }); + }); + + it("separates encoded filename characters from a video playback fragment", () => { + expect(resolveMarkdownLinkPresentation("/tmp/clip%23one.mp4#t=2")).toMatchObject({ + path: "/tmp/clip#one.mp4", + label: "clip#one.mp4", + icon: "video", + }); + }); + it("extracts external link hosts", () => { expect(resolveMarkdownLinkPresentation("https://example.com/docs?q=1")).toEqual({ kind: "external", @@ -11,15 +27,16 @@ describe("resolveMarkdownLinkPresentation", () => { }); }); - it("renders file URLs as basename pills with positions", () => { - expect( - resolveMarkdownLinkPresentation("file:///Users/julius/project/src/main.ts#L42C7"), - ).toEqual({ + it.each([ + ["file:///Users/julius/project/src/main.ts#L42C7", "/Users/julius/project/src/main.ts"], + ["file://server/share/src/main.ts#L42C7", "\\\\server\\share\\src\\main.ts"], + ])("preserves the file URL path and position for %s", (href, path) => { + expect(resolveMarkdownLinkPresentation(href)).toEqual({ kind: "file", - href: "file:///Users/julius/project/src/main.ts#L42C7", + href, icon: "typescript", label: "main.ts:42:7", - path: "/Users/julius/project/src/main.ts", + path, line: 42, column: 7, }); diff --git a/apps/mobile/src/lib/markdownMedia.test.ts b/apps/mobile/src/lib/markdownMedia.test.ts new file mode 100644 index 00000000000..77834630dc2 --- /dev/null +++ b/apps/mobile/src/lib/markdownMedia.test.ts @@ -0,0 +1,77 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveMarkdownMediaPreview } from "./markdownMedia"; + +const input = { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + workspaceRoot: "/repo", +}; + +describe("resolveMarkdownMediaPreview", () => { + it("decodes remote filenames once without changing the authored URL", () => { + const href = "https://cdn.example.com/clip%20one%2520%2Emp4?signature=a%2fb#t=2"; + expect(resolveMarkdownMediaPreview(href, input)).toMatchObject({ + kind: "video", + source: { + uri: href, + actionsSource: { + name: "clip one%20.mp4", + mimeType: "video/mp4", + reference: { kind: "url", url: href }, + }, + }, + }); + }); + + it("provides extensionless image actions only for image embeds", () => { + const href = "https://cdn.example.com/render?id=42"; + expect(resolveMarkdownMediaPreview(href, input)).toBeNull(); + expect(resolveMarkdownMediaPreview(href, { ...input, imageEmbed: true })).toMatchObject({ + kind: "image", + source: { actionsSource: { reference: { kind: "url", url: href }, mimeType: "image/*" } }, + }); + }); + + it.each([ + ["/tmp/frame%23one.png:12", "/tmp/frame#one.png"], + ["/tmp/frame%3Fone.png:12:3", "/tmp/frame?one.png"], + ["/tmp/frame%2523one.png:12", "/tmp/frame%23one.png"], + ["file://server/share/frame.png", "\\\\server\\share\\frame.png"], + ["\\\\server\\share\\frame.png", "\\\\server\\share\\frame.png"], + ])("keeps encoded filename and UNC semantics for %s", (href, path) => { + expect(resolveMarkdownMediaPreview(href, input)).toMatchObject({ + kind: "image", + source: { + resource: { path }, + actionsSource: { reference: { kind: "file", path } }, + }, + }); + }); + + it("separates a video playback fragment from literal filename characters", () => { + expect(resolveMarkdownMediaPreview("/tmp/clip%23one.mp4#t=2", input)).toMatchObject({ + kind: "video", + source: { + srcFragment: "#t=2", + resource: { path: "/tmp/clip#one.mp4" }, + actionsSource: { reference: { kind: "file", path: "/tmp/clip#one.mp4" } }, + }, + }); + }); + + it("resolves protocol-relative media for native APIs without rewriting its signed query", () => { + expect( + resolveMarkdownMediaPreview("//cdn.example.com/clip.mp4?signature=a%2fb#t=2", input), + ).toMatchObject({ + kind: "video", + source: { + uri: "https://cdn.example.com/clip.mp4?signature=a%2fb#t=2", + actionsSource: { + reference: { kind: "url", url: "//cdn.example.com/clip.mp4?signature=a%2fb#t=2" }, + }, + }, + }); + }); +}); diff --git a/apps/mobile/src/lib/markdownMedia.ts b/apps/mobile/src/lib/markdownMedia.ts new file mode 100644 index 00000000000..2196c2f22f2 --- /dev/null +++ b/apps/mobile/src/lib/markdownMedia.ts @@ -0,0 +1,89 @@ +import { + classifyMarkdownImageSource, + markdownImageSourceFragment, +} from "@t3tools/client-runtime/markdown-images"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links"; +import { mediaMimeType, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; +import { + mediaFileReference, + mediaReferenceFileName, + mediaUrlReference, +} from "@t3tools/client-runtime/media-reference"; + +import type { FilePreviewSource } from "../components/FilePreviewModal"; +import type { MediaVideoPreviewSource } from "./videoPreviewSource"; +import type { MediaActionsSource } from "./mediaActions"; + +/** Resolves only explicit media references. Ordinary links keep their existing navigation. */ +export function resolveMarkdownMediaPreview( + href: string, + input: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly workspaceRoot: string | null | undefined; + /** Image syntax can target an endpoint without a recognizable extension. */ + readonly imageEmbed?: boolean; + }, +): + | { readonly kind: "image"; readonly source: FilePreviewSource } + | { readonly kind: "video"; readonly source: MediaVideoPreviewSource } + | null { + const classified = classifyMarkdownImageSource(href, input.workspaceRoot); + if (classified._tag === "Blocked") return null; + const path = + classified._tag === "WorkspaceFile" + ? classified.path.replace(/:\d+(?::\d+)?$/, "") + : classified.uri.split(/[?#]/, 1)[0]!; + const basename = path.split(/[\\/]/).at(-1) ?? ""; + const extensionIndex = basename.lastIndexOf("."); + // Local paths have already been decoded. Do not interpret literal #, ?, or % characters again. + const detectedMimeType = + classified._tag === "Direct" + ? mediaMimeType(classified.uri) + : extensionIndex < 0 + ? null + : mediaMimeTypeFromExtension(basename.slice(extensionIndex)); + const mimeType = detectedMimeType ?? (input.imageEmbed ? "image/*" : null); + if (mimeType === null) return null; + const kind = mimeType.startsWith("video/") ? "video" : "image"; + const reference = + classified._tag === "Direct" + ? mediaUrlReference(classified.uri) + : mediaFileReference(path, input.workspaceRoot); + const name = + (reference && mediaReferenceFileName(reference)) || (kind === "video" ? "Video" : "Image"); + const srcFragment = markdownImageSourceFragment(href); + const target = + classified._tag === "Direct" + ? { uri: normalizeNativeMarkdownUrl(classified.uri) } + : { + environmentId: input.environmentId, + resource: { + _tag: "media-file" as const, + threadId: input.threadId, + path, + }, + ...(srcFragment ? { srcFragment } : {}), + }; + const actionsSource: MediaActionsSource = + classified._tag === "Direct" + ? { reference, uri: classified.uri, name, mimeType } + : { + reference, + environmentId: input.environmentId, + threadId: input.threadId, + resource: { _tag: "media-file", threadId: input.threadId, path }, + name, + mimeType, + }; + return kind === "video" + ? { + kind, + source: { type: "media", name, mimeType, ...target, actionsSource }, + } + : { + kind, + source: { kind, name, ...target, actionsSource }, + }; +} diff --git a/apps/mobile/src/lib/mediaActions.ts b/apps/mobile/src/lib/mediaActions.ts new file mode 100644 index 00000000000..14dc2de7bd2 --- /dev/null +++ b/apps/mobile/src/lib/mediaActions.ts @@ -0,0 +1,121 @@ +import { useNavigation } from "@react-navigation/native"; +import type { MediaReference } from "@t3tools/client-runtime/media-reference"; +import type { AssetResource, EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links"; +import { useEffect, useRef, useState } from "react"; +import { Alert } from "react-native"; + +import { useRefreshAssetUrl } from "../state/assets"; +import { downloadAndShareAttachment, shareLocalAttachment } from "./attachmentDownload"; +import { copyTextWithHaptic } from "./copyTextWithHaptic"; + +/** Authored source metadata is kept separate from temporary preview/download URLs. */ +export type MediaActionsSource = { + readonly reference?: MediaReference; + readonly name: string; + readonly mimeType: string; +} & ( + | { readonly uri: string } + | { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly resource: AssetResource; + } +); + +export function useMediaActions(source: MediaActionsSource | undefined, onOpenFile?: () => void) { + const navigation = useNavigation(); + const refresh = useRefreshAssetUrl( + source && "environmentId" in source ? source.environmentId : null, + source && "resource" in source ? source.resource : null, + ); + const controller = useRef(null); + const [sharing, setSharing] = useState(false); + useEffect(() => () => controller.current?.abort(), []); + + const share = () => { + if (!source || controller.current) return; + const request = new AbortController(); + controller.current = request; + setSharing(true); + void (async () => { + const uri = "uri" in source ? normalizeNativeMarkdownUrl(source.uri) : await refresh(); + if (request.signal.aborted) return; + if (uri === null) throw new Error("The file could not be loaded. Reconnect and try again."); + const input = { + attachment: { name: source.name, mimeType: source.mimeType }, + signal: request.signal, + }; + if (/^(file|content):/i.test(uri)) await shareLocalAttachment({ ...input, uri }); + else await downloadAndShareAttachment({ ...input, url: uri }); + })() + .catch((error: unknown) => { + if (!request.signal.aborted) { + Alert.alert( + "Could not share file", + error instanceof Error ? error.message : "Try again.", + ); + } + }) + .finally(() => { + if (controller.current === request) { + controller.current = null; + if (!request.signal.aborted) setSharing(false); + } + }); + }; + + const reference = source?.reference; + const actions: { id: string; title: string; run: () => void; disabled?: boolean }[] = source + ? [ + ...(reference?.kind === "file" + ? [ + { + id: "copy-path", + title: "Copy full path", + run: () => copyTextWithHaptic(reference.path), + }, + ...(reference.relativePath + ? [ + { + id: "copy-relative-path", + title: "Copy relative path", + run: () => copyTextWithHaptic(reference.relativePath!), + }, + ] + : []), + ...(reference.relativePath && source && "environmentId" in source + ? [ + { + id: "open-file", + title: "Open in file viewer", + run: () => { + onOpenFile?.(); + navigation.navigate("ThreadFile", { + environmentId: String(source.environmentId), + threadId: String(source.threadId), + path: reference.relativePath!.split("/"), + }); + }, + }, + ] + : []), + ] + : reference + ? [{ id: "copy-url", title: "Copy URL", run: () => copyTextWithHaptic(reference.url) }] + : []), + { + id: "share", + title: sharing ? "Opening share sheet…" : "Save or share", + run: share, + disabled: sharing, + }, + ] + : []; + return { + title: reference?.kind === "file" ? reference.path : reference?.url, + actions, + sharing, + share, + }; +} diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts index 867d9e98301..1e7cb5f3164 100644 --- a/apps/mobile/src/lib/nativeMarkdownText.test.ts +++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts @@ -11,6 +11,43 @@ import { } from "@t3tools/mobile-markdown-text/markdown"; describe("nativeMarkdownTextRuns", () => { + it("links a path-shaped code span without changing the same path in prose", () => { + expect( + nativeMarkdownTextRuns({ + type: "paragraph", + children: [ + { type: "text", content: "/tmp/frame.png " }, + { type: "code_inline", content: "/tmp/frame.png" }, + ], + }), + ).toEqual([ + { text: "/tmp/frame.png " }, + { text: "frame.png", href: "/tmp/frame.png", fileIcon: "image" }, + ]); + }); + + it("preserves the destination of a link with a code-formatted label", () => { + expect( + nativeMarkdownTextRuns({ + type: "paragraph", + children: [ + { + type: "link", + href: "https://example.com/docs", + children: [{ type: "code_inline", content: "src/main.ts" }], + }, + ], + }), + ).toEqual([ + { + text: "src/main.ts", + code: true, + href: "https://example.com/docs", + externalHost: "example.com", + }, + ]); + }); + it("preserves inline emphasis and code styles", () => { const node: MarkdownNode = { type: "paragraph", diff --git a/apps/mobile/src/lib/videoPreviewSource.ts b/apps/mobile/src/lib/videoPreviewSource.ts new file mode 100644 index 00000000000..af87a8d0f73 --- /dev/null +++ b/apps/mobile/src/lib/videoPreviewSource.ts @@ -0,0 +1,54 @@ +import type { AssetResource, ChatFileAttachment, EnvironmentId } from "@t3tools/contracts"; + +import type { DraftComposerFileAttachment } from "./composerImages"; +import type { MediaActionsSource } from "./mediaActions"; + +export type MediaVideoPreviewSource = { + readonly type: "media"; + readonly name: string; + readonly mimeType: string; + readonly sourceIdentifier?: string; + readonly srcFragment?: string; + readonly actionsSource?: MediaActionsSource; +} & ( + | { readonly uri: string } + | { + readonly environmentId: EnvironmentId; + readonly resource: Extract; + } +); + +/** Resolves the current capability without making it the identity of the video. */ +export function mediaVideoPreviewUri( + source: MediaVideoPreviewSource, + assetUrl: string | null, +): string | null { + if ("uri" in source) return source.uri; + return assetUrl === null ? null : assetUrl + (source.srcFragment ?? ""); +} + +/** Keeps thumbnails independent of refreshed asset signatures and scoped to their environment. */ +export function mediaVideoThumbnailKey(source: MediaVideoPreviewSource): string { + return JSON.stringify( + "uri" in source + ? ["media-video", source.uri] + : [ + "media-video", + source.environmentId, + source.resource.threadId, + source.resource.path, + source.srcFragment ?? "", + ], + ); +} + +export type AttachmentVideoPreviewSource = ( + | { readonly type: "local"; readonly attachment: DraftComposerFileAttachment } + | { + readonly type: "remote"; + readonly environmentId: EnvironmentId; + readonly attachment: ChatFileAttachment; + } +) & { readonly sourceIdentifier?: string }; + +export type VideoPreviewSource = AttachmentVideoPreviewSource | MediaVideoPreviewSource; diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index 611a1ed8b99..9e3e43c7cdc 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -2,9 +2,11 @@ import { useAtomValue } from "@effect/atom-react"; import { createAssetEnvironmentAtoms, resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useCallback } from "react"; import { connectionAtomRuntime } from "../connection/runtime"; import { usePreparedConnection } from "./session"; +import { useAtomQueryRunner } from "./use-atom-query-runner"; export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); @@ -44,3 +46,23 @@ export function useAssetUrl( const state = useAssetUrlState(environmentId, resource); return state._tag === "Success" ? state.url : null; } + +/** Explicit playback and sharing must reauthorize files that may have been replaced on disk. */ +export function useRefreshAssetUrl( + environmentId: EnvironmentId | null, + resource: AssetResource | null, +): () => Promise { + const connection = usePreparedConnection(environmentId); + const httpBaseUrl = connection._tag === "Some" ? connection.value.httpBaseUrl : null; + const createUrl = useAtomQueryRunner(assetEnvironment.createUrl, { + refresh: true, + reportFailure: false, + }); + return useCallback(async () => { + if (environmentId === null || resource === null || httpBaseUrl === null) return null; + const result = await createUrl({ environmentId, input: { resource } }); + return result._tag === "Success" + ? resolveAssetUrl(httpBaseUrl, result.value.relativeUrl) + : null; + }, [createUrl, environmentId, httpBaseUrl, resource]); +} diff --git a/apps/mobile/src/state/use-atom-query-runner.ts b/apps/mobile/src/state/use-atom-query-runner.ts index 22f971e09a5..691b1f43cb8 100644 --- a/apps/mobile/src/state/use-atom-query-runner.ts +++ b/apps/mobile/src/state/use-atom-query-runner.ts @@ -1,7 +1,7 @@ import { RegistryContext } from "@effect/atom-react"; import { executeAtomQuery, - type AtomCommandOptions, + type AtomQueryOptions, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import { AsyncResult, type Atom } from "effect/unstable/reactivity"; @@ -9,12 +9,13 @@ import { useCallback, useContext } from "react"; export function useAtomQueryRunner( family: (target: T) => Atom.Atom>, - options?: string | AtomCommandOptions, + options?: string | AtomQueryOptions, ): (target: T) => Promise> { const registry = useContext(RegistryContext); const explicitLabel = typeof options === "string" ? options : options?.label; const reportFailure = typeof options === "string" ? true : (options?.reportFailure ?? true); const reportDefect = typeof options === "string" ? true : (options?.reportDefect ?? true); + const refresh = typeof options === "string" ? false : (options?.refresh ?? false); return useCallback( (target: T) => { @@ -23,8 +24,9 @@ export function useAtomQueryRunner( label: explicitLabel ?? atom.label?.[0] ?? "atom query", reportFailure, reportDefect, + refresh, }); }, - [explicitLabel, family, registry, reportDefect, reportFailure], + [explicitLabel, family, registry, refresh, reportDefect, reportFailure], ); } diff --git a/apps/server/package.json b/apps/server/package.json index 9aa21a5d232..93f012cb99c 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.50", + "version": "0.0.51", "license": "MIT", "repository": { "type": "git", diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 8cf9c642384..4a47a17fabc 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -1,4 +1,7 @@ +// @effect-diagnostics nodeBuiltinImport:off - tests inject swaps at the native open boundary. import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; +import * as NodeFSP from "node:fs/promises"; import { AssetPreviewTypeValidationError, ThreadId } from "@t3tools/contracts"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import { describe, expect, it } from "@effect/vitest"; @@ -9,18 +12,28 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as TestClock from "effect/testing/TestClock"; +import { HttpServerResponse } from "effect/unstable/http"; +import { vi } from "vite-plus/test"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerConfig from "../config.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; +import { assetFileResponse } from "../http.ts"; import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts"; +import { openMediaFile } from "./MediaFile.ts"; + +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, open: vi.fn(actual.open), realpath: vi.fn(actual.realpath) }; +}); const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-asset-access-test-", }); const testLayer = Layer.mergeAll( + NodeHttpPlatform.layer, configLayer, WorkspacePaths.layer, ProjectFaviconResolver.layer.pipe( @@ -31,6 +44,328 @@ const testLayer = Layer.mergeAll( ).pipe(Layer.provideMerge(NodeServices.layer)); describe("AssetAccess", () => { + it.effect("issues exact URLs for images and videos outside the workspace", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-root-" }); + const outside = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-outside-" }); + for (const [name, mimeType] of [ + ["screenshot.png", "image/png"], + ["recording.mp4", "video/mp4"], + ["recording.webm", "video/webm"], + ] as const) { + const filePath = path.join(outside, name); + yield* fs.writeFileString(filePath, "media"); + const canonicalFile = yield* fs.realPath(filePath); + const result = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath }, + workspaceRoot: root, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + const token = suffix.slice(0, separator); + expect(yield* resolveAsset(token, suffix.slice(separator + 1))).toMatchObject({ + kind: "file", + path: canonicalFile, + mimeType, + }); + yield* fs.writeFileString(path.join(outside, "sibling.png"), "private sibling"); + expect(yield* resolveAsset(token, "sibling.png")).toBeNull(); + expect(yield* resolveAsset(token, `../${name}`)).toBeNull(); + expect(yield* resolveAsset(`${token}tampered`, name)).toBeNull(); + } + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("resolves relative media paths from the thread workspace, including outside it", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-relative-" }); + const root = path.join(directory, "workspace"); + yield* fs.makeDirectory(root); + for (const relativePath of ["screenshot.png", "../recording.mp4"]) { + const filePath = path.resolve(root, relativePath); + yield* fs.writeFileString(filePath, "media"); + const result = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: relativePath }, + workspaceRoot: root, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + expect( + yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)), + ).toMatchObject({ + kind: "file", + path: yield* fs.realPath(filePath), + }); + } + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects non-media files, disguised targets, and directories", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-validation-" }); + for (const name of ["report.html", "secret.txt", "secret.%70ng", "secret.png#private.txt"]) { + const filePath = path.join(root, name); + yield* fs.writeFileString(filePath, "not media"); + const error = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath }, + }).pipe(Effect.flip); + expect(error).toBeInstanceOf(AssetPreviewTypeValidationError); + } + const disguisedPath = path.join(root, "disguised.png"); + yield* fs.symlink(path.join(root, "report.html"), disguisedPath); + const disguisedError = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: disguisedPath }, + }).pipe(Effect.flip); + expect(disguisedError).toBeInstanceOf(AssetPreviewTypeValidationError); + const directoryPath = path.join(root, "directory.png"); + yield* fs.makeDirectory(directoryPath); + const directoryError = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: directoryPath }, + }).pipe(Effect.flip); + expect(directoryError._tag).toBe("AssetWorkspaceAssetNotFoundError"); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("binds media URLs to the canonical target and rejects symlink substitution", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-symlink-" }); + const filePath = path.join(root, "actual.svg"); + const aliasPath = path.join(root, "alias.png"); + const replacementPath = path.join(root, "other.svg"); + yield* fs.writeFileString(filePath, ""); + yield* fs.writeFileString(replacementPath, "private"); + yield* fs.symlink(filePath, aliasPath); + const canonicalFile = yield* fs.realPath(filePath); + const result = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: aliasPath }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + const token = suffix.slice(0, separator); + const name = suffix.slice(separator + 1); + const expected = { kind: "file", path: canonicalFile, mimeType: "image/svg+xml" }; + expect(yield* resolveAsset(token, name)).toMatchObject(expected); + yield* fs.remove(aliasPath); + yield* fs.symlink(replacementPath, aliasPath); + expect(yield* resolveAsset(token, name)).toMatchObject(expected); + yield* fs.remove(filePath); + yield* fs.symlink(replacementPath, filePath); + expect(yield* resolveAsset(token, name)).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("keeps full and partial responses bound to the file opened during resolution", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-file-" }); + const filePath = path.join(root, "recording.mp4"); + const savedPath = path.join(root, "saved.mp4"); + const secretPath = path.join(root, "secret.txt"); + yield* fs.writeFileString(filePath, "0123456789"); + yield* fs.writeFileString(secretPath, "private information"); + const result = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + for (const [range, expected, status] of [ + [undefined, "0123456789", 200], + ["bytes=2-5", "2345", 206], + ] as const) { + const asset = yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)); + if (!asset) throw new Error("Expected a resolved media file"); + + yield* fs.rename(filePath, savedPath); + yield* fs.symlink(secretPath, filePath); + const response = HttpServerResponse.toWeb(yield* assetFileResponse(asset, range)); + expect(response.status).toBe(status); + expect(response.headers.get("content-length")).toBe(String(expected.length)); + expect(yield* Effect.promise(() => response.text())).toBe(expected); + yield* fs.remove(filePath); + yield* fs.rename(savedPath, filePath); + } + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects a symlink swapped in after canonical validation but before open", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-race-" }); + const filePath = path.join(root, "recording.mp4"); + const secretPath = path.join(root, "secret.txt"); + yield* fs.writeFileString(filePath, "video"); + yield* fs.writeFileString(secretPath, "secret"); + const canonicalPath = yield* fs.realPath(filePath); + const result = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + const swappingFileSystem = FileSystem.FileSystem.of({ + ...fs, + stat: Effect.fn(function* (requestedPath) { + const info = yield* fs.stat(requestedPath); + if (requestedPath === canonicalPath) { + yield* fs.remove(filePath); + yield* fs.symlink(secretPath, filePath); + } + return info; + }), + }); + expect( + yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)).pipe( + Effect.provideService(FileSystem.FileSystem, swappingFileSystem), + ), + ).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("closes a descriptor rejected when its path changes during open", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-rejected-" }); + const filePath = path.join(root, "recording.mp4"); + const secretPath = path.join(root, "secret.txt"); + yield* fs.writeFileString(filePath, "video"); + yield* fs.writeFileString(secretPath, "secret"); + const canonicalPath = yield* fs.realPath(filePath); + const originalOpen = (yield* Effect.promise(() => + vi.importActual("node:fs/promises"), + )).open; + let opened: NodeFSP.FileHandle | undefined; + const openSpy = vi.mocked(NodeFSP.open).mockImplementation(async (target, flags, mode) => { + const handle = await originalOpen(target, flags, mode); + if (target === canonicalPath) { + opened = handle; + await NodeFSP.unlink(filePath); + await NodeFSP.symlink(secretPath, filePath); + } + return handle; + }); + yield* Effect.addFinalizer(() => Effect.sync(() => openSpy.mockImplementation(originalOpen))); + expect(yield* openMediaFile(canonicalPath)).toBeNull(); + expect(opened).toBeDefined(); + expect(opened?.fd).toBe(-1); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects an ancestor symlink race even when canonical path rechecks would pass", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-parent-race-" }); + const publicDirectory = path.join(root, "public"); + const privateDirectory = path.join(root, "private"); + yield* fs.makeDirectory(publicDirectory); + yield* fs.makeDirectory(privateDirectory); + const filePath = path.join(publicDirectory, "recording.mp4"); + yield* fs.writeFileString(filePath, "public video"); + yield* fs.writeFileString(path.join(privateDirectory, "recording.mp4"), "private video"); + const canonicalPath = yield* fs.realPath(filePath); + const result = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + const native = yield* Effect.promise(() => + vi.importActual("node:fs/promises"), + ); + const savedDirectory = path.join(root, "saved"); + const realpathSpy = vi.mocked(NodeFSP.realpath).mockImplementationOnce(async () => { + // A pathname-only guard can see the original parents during realpath, + // but the private file during both lstat calls and open. + await native.unlink(publicDirectory); + await native.rename(savedDirectory, publicDirectory); + const canonical = await native.realpath(canonicalPath); + await native.rename(publicDirectory, savedDirectory); + await native.symlink(privateDirectory, publicDirectory, "junction"); + return canonical; + }); + yield* Effect.addFinalizer(() => + Effect.sync(() => realpathSpy.mockReset().mockImplementation(native.realpath)), + ); + const swappingFileSystem = FileSystem.FileSystem.of({ + ...fs, + realPath: Effect.fn(function* (requestedPath) { + const canonical = yield* fs.realPath(requestedPath); + if (requestedPath === canonicalPath) { + yield* fs.rename(publicDirectory, savedDirectory); + yield* Effect.promise(() => + NodeFSP.symlink(privateDirectory, publicDirectory, "junction"), + ); + } + return canonical; + }), + }); + expect( + yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)).pipe( + Effect.provideService(FileSystem.FileSystem, swappingFileSystem), + ), + ).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("keeps in-place edits readable but requires a new URL after atomic replacement", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-replacement-" }); + const filePath = path.join(root, "recording.mp4"); + yield* fs.writeFileString(filePath, "original"); + const input = { + resource: { + _tag: "media-file" as const, + threadId: ThreadId.make("thread-1"), + path: filePath, + }, + }; + const original = yield* issueAssetUrl(input); + const suffix = original.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + const token = suffix.slice(0, separator); + const name = suffix.slice(separator + 1); + yield* fs.writeFileString(filePath, "in-place edit"); + const edited = yield* resolveAsset(token, name); + if (!edited) throw new Error("Expected the edited media file"); + const editedResponse = HttpServerResponse.toWeb(yield* assetFileResponse(edited)); + expect(yield* Effect.promise(() => editedResponse.text())).toBe("in-place edit"); + + const replacement = path.join(root, "replacement.mp4"); + yield* fs.writeFileString(replacement, "replacement"); + yield* fs.rename(replacement, filePath); + expect(yield* resolveAsset(token, name)).toBeNull(); + + const renewed = yield* issueAssetUrl(input); + const renewedSuffix = renewed.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const renewedSeparator = renewedSuffix.indexOf("/"); + const renewedAsset = yield* resolveAsset( + renewedSuffix.slice(0, renewedSeparator), + renewedSuffix.slice(renewedSeparator + 1), + ); + if (!renewedAsset) throw new Error("Expected the replacement media file"); + const renewedResponse = HttpServerResponse.toWeb(yield* assetFileResponse(renewedAsset)); + expect(yield* Effect.promise(() => renewedResponse.text())).toBe("replacement"); + yield* fs.remove(filePath); + expect( + yield* resolveAsset( + renewedSuffix.slice(0, renewedSeparator), + renewedSuffix.slice(renewedSeparator + 1), + ), + ).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("issues workspace URLs that resolve the entry file and sibling assets", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index d064ec07529..05801acca88 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -16,6 +16,7 @@ import { import { isWorkspaceImagePreviewPath, isWorkspacePreviewEntryPath, + mediaMimeTypeFromExtension, WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, } from "@t3tools/shared/filePreview"; @@ -41,6 +42,7 @@ import { parseAttachmentFileExtension, resolveAttachmentPathById } from "../atta import * as ServerConfig from "../config.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; +import { openMediaFile, type OpenMediaFile } from "./MediaFile.ts"; export const ASSET_ROUTE_PREFIX = "/api/assets"; @@ -76,6 +78,14 @@ const AssetClaimsSchema = Schema.Union([ relativePath: Schema.String, expiresAt: Schema.Number, }), + Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("media-file-exact"), + filePath: Schema.String, + device: Schema.String, + inode: Schema.String, + expiresAt: Schema.Number, + }), Schema.Struct({ version: Schema.Literal(1), kind: Schema.Literal("attachment"), @@ -115,6 +125,7 @@ export type ResolvedAsset = { readonly download?: boolean; readonly fileName?: string; readonly mimeType?: string; + readonly file?: OpenMediaFile; }; function decodeClaims(encodedPayload: string): AssetClaims | null { @@ -211,6 +222,55 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i let sourcePath: string | undefined; switch (input.resource._tag) { + case "media-file": { + let requestedPath = input.resource.path; + if (!path.isAbsolute(requestedPath)) { + if (!input.workspaceRoot) { + return yield* new AssetWorkspaceContextNotFoundError({ resource: input.resource }); + } + const workspaceRoot = yield* workspacePaths + .normalizeWorkspaceRoot(input.workspaceRoot) + .pipe( + Effect.mapError( + (cause) => + new AssetWorkspaceRootNormalizationError({ resource: input.resource, cause }), + ), + ); + requestedPath = path.resolve(workspaceRoot, requestedPath); + } + const canonicalFile = yield* resolveCanonicalFile(requestedPath).pipe( + Effect.mapError( + (cause) => new AssetWorkspaceAssetInspectionError({ resource: input.resource, cause }), + ), + ); + if (!canonicalFile) { + return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource }); + } + if (mediaMimeTypeFromExtension(path.extname(canonicalFile)) === null) { + return yield* new AssetPreviewTypeValidationError({ resource: input.resource }); + } + const identity = yield* openMediaFile(canonicalFile).pipe( + Effect.map((file) => + file ? { device: file.info.dev.toString(), inode: file.info.ino.toString() } : null, + ), + Effect.scoped, + Effect.mapError( + (cause) => new AssetWorkspaceAssetInspectionError({ resource: input.resource, cause }), + ), + ); + if (!identity) { + return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource }); + } + claims = { + version: 1, + kind: "media-file-exact", + filePath: canonicalFile, + ...identity, + expiresAt, + }; + fileName = path.basename(canonicalFile); + break; + } case "workspace-file": { if (!input.workspaceRoot) { return yield* new AssetWorkspaceContextNotFoundError({ @@ -526,6 +586,30 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( const decodedPath = decodeRelativePath(relativePath); if (decodedPath === null) return null; const path = yield* Path.Path; + if (claims.kind === "media-file-exact") { + if (decodedPath !== path.basename(claims.filePath)) return null; + const canonicalFile = yield* resolveCanonicalFile(claims.filePath).pipe( + Effect.tapError((cause) => + Effect.logError("Failed to resolve canonical media path.", { + filePath: claims.filePath, + cause, + }), + ), + Effect.orElseSucceed(() => null), + ); + if (canonicalFile !== claims.filePath) return null; + const mimeType = mediaMimeTypeFromExtension(path.extname(canonicalFile)); + if (!mimeType) return null; + const file = yield* openMediaFile(canonicalFile, claims).pipe( + Effect.tapError((cause) => + Effect.logError("Failed to open canonical media file.", { filePath: canonicalFile, cause }), + ), + Effect.orElseSucceed(() => null), + ); + return file + ? ({ kind: "file", path: canonicalFile, mimeType, file } satisfies ResolvedAsset) + : null; + } if (claims.kind === "workspace-file-exact") { if (decodedPath !== path.basename(claims.relativePath)) return null; const exactWorkspaceFile = yield* resolveCanonicalWorkspaceFileForRequest({ diff --git a/apps/server/src/assets/MediaFile.ts b/apps/server/src/assets/MediaFile.ts new file mode 100644 index 00000000000..f1b63bb659e --- /dev/null +++ b/apps/server/src/assets/MediaFile.ts @@ -0,0 +1,113 @@ +// @effect-diagnostics nodeBuiltinImport:off - FileSystem does not expose no-follow +// or non-blocking open flags, and the response must keep the validated descriptor. +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; + +import * as NodeStream from "@effect/platform-node/NodeStream"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +class MediaFileOpenError extends Schema.TaggedErrorClass()( + "MediaFileOpenError", + { + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to open media file '${this.path}'.`; + } +} + +class MediaFileStatError extends Schema.TaggedErrorClass()( + "MediaFileStatError", + { + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read metadata for media file '${this.path}'.`; + } +} + +/** Holds the file identity and descriptor for one HTTP request, never a copy of its bytes. */ +export interface OpenMediaFile { + readonly handle: NodeFSP.FileHandle; + readonly info: NodeFS.BigIntStats; +} + +/** Opens a canonical media path once. Replacements cannot change the response's source. */ +export const openMediaFile = Effect.fn("openMediaFile")(function* ( + filePath: string, + identity?: { readonly device: string; readonly inode: string }, +) { + return yield* Effect.acquireRelease( + Effect.tryPromise({ + try: async () => { + const before = await NodeFSP.lstat(filePath, { bigint: true }); + if (!before.isFile() || before.ino === 0n) return null; + if ( + identity && + (before.dev.toString() !== identity.device || before.ino.toString() !== identity.inode) + ) { + return null; + } + + // Windows lacks these flags; the descriptor/path identity checks still apply. + const handle = await NodeFSP.open( + filePath, + NodeFS.constants.O_RDONLY | + (NodeFS.constants.O_NOFOLLOW ?? 0) | + (NodeFS.constants.O_NONBLOCK ?? 0), + ); + let accepted = false; + try { + const info = await handle.stat({ bigint: true }); + if (!info.isFile() || info.dev !== before.dev || info.ino !== before.ino) return null; + if ( + identity && + (info.dev.toString() !== identity.device || info.ino.toString() !== identity.inode) + ) { + return null; + } + if ((await NodeFSP.realpath(filePath)) !== filePath) return null; + const after = await NodeFSP.lstat(filePath, { bigint: true }); + if (!after.isFile() || info.dev !== after.dev || info.ino !== after.ino) return null; + accepted = true; + return { handle, info } satisfies OpenMediaFile; + } finally { + if (!accepted) await handle.close(); + } + }, + catch: (cause) => new MediaFileOpenError({ path: filePath, cause }), + }), + (file) => (file ? Effect.promise(() => file.handle.close()) : Effect.void), + ); +}); + +export const statMediaFile = Effect.fn("statMediaFile")(function* ( + filePath: string, + file: OpenMediaFile, +) { + return yield* Effect.tryPromise({ + try: () => file.handle.stat({ bigint: true }), + catch: (cause) => new MediaFileStatError({ path: filePath, cause }), + }); +}); + +export const streamMediaFile = (file: OpenMediaFile, offset: bigint, bytesToRead: bigint) => { + const start = Number(offset); + const end = Number(offset + bytesToRead - 1n); + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start) { + return null; + } + return NodeStream.fromReadable({ + evaluate: () => + file.handle.createReadStream({ + autoClose: false, + start, + end, + }), + }); +}; diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index f775479c247..daa65eb80a6 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -8,6 +8,7 @@ import * as CliError from "effect/unstable/cli/CliError"; import * as NetService from "@t3tools/shared/Net"; import packageJson from "../package.json" with { type: "json" }; import { authCommand } from "./cli/auth.ts"; +import { appCommand } from "./cli/app.ts"; import { connectCommand } from "./cli/connect.ts"; import { pairCommand } from "./cli/pair.ts"; import { officialImportCommand } from "./cli/officialImport.ts"; @@ -54,6 +55,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => Command.withSubcommands([ startCommand, serveCommand, + appCommand, pairCommand, authCommand, projectCommand, diff --git a/apps/server/src/cli/app.test.ts b/apps/server/src/cli/app.test.ts new file mode 100644 index 00000000000..d122c875fa3 --- /dev/null +++ b/apps/server/src/cli/app.test.ts @@ -0,0 +1,311 @@ +// @effect-diagnostics nodeBuiltinImport:off -- The integration fixture binds the same platform socket or named pipe as the CLI. +import * as NodeFSP from "node:fs/promises"; +import * as NodeNet from "node:net"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import type { DesktopAppActivationRequest } from "@t3tools/contracts"; +import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl"; +import { + HostProcessPlatform, + HostProcessUserId, + HostProcessWorkingDirectory, +} from "@t3tools/shared/hostProcess"; +import * as NetService from "@t3tools/shared/Net"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { Command } from "effect/unstable/cli"; +import { afterEach, describe, expect, vi } from "vite-plus/test"; + +import { makeCli } from "../bin.ts"; + +vi.mock("node:os", async (importOriginal) => { + const os = await importOriginal(); + return { ...os, homedir: vi.fn(os.homedir) }; +}); + +afterEach(() => vi.mocked(NodeOS.homedir).mockReset()); + +// T3 Turbo keeps its state in ~/.t3-turbo, so the default home these tests exercise is +// the fork's directory rather than upstream's ~/.t3. +const defaultStateHomeName = ".t3-turbo"; + +const runCli = (args: ReadonlyArray, env: Record = {}) => + Command.runWith(makeCli(), { version: "0.0.0" })(args).pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + NetService.layer, + ConfigProvider.layer(ConfigProvider.fromEnv({ env })), + ), + ), + ); + +const pathExists = (path: string) => + Effect.promise(() => + NodeFSP.stat(path).then( + () => true, + () => false, + ), + ); + +async function startFakeDesktop(input: { + readonly baseDir: string; + readonly stateSubdirectory?: "userdata" | "dev"; + readonly platform: NodeJS.Platform; + readonly userId: number | undefined; + readonly reply?: (request: DesktopAppActivationRequest) => unknown; +}) { + const target = resolveDesktopAppControlAddress({ + stateDir: NodePath.join(input.baseDir, input.stateSubdirectory ?? "userdata"), + platform: input.platform, + tempDir: NodeOS.tmpdir(), + userId: input.userId, + joinPath: NodePath.join, + }); + if (target.directory !== null) { + await NodeFSP.mkdir(target.directory, { recursive: true, mode: 0o700 }); + await NodeFSP.unlink(target.address).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + }); + } + + const received: DesktopAppActivationRequest[] = []; + const server = NodeNet.createServer((socket) => { + socket.setEncoding("utf8"); + let buffer = ""; + socket.on("data", (chunk) => { + buffer += chunk; + const newline = buffer.indexOf("\n"); + if (newline === -1) return; + const request = JSON.parse(buffer.slice(0, newline)) as DesktopAppActivationRequest; + received.push(request); + const response = input.reply + ? input.reply(request) + : { + version: 1, + requestId: request.requestId, + ok: true, + projectId: "project-1", + threadId: `thread-${received.length}`, + }; + socket.end(`${JSON.stringify(response)}\n`); + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(target.address, resolve); + }); + + return { + received, + close: async () => { + await new Promise((resolve) => server.close(() => resolve())); + if (target.directory !== null) { + await NodeFSP.unlink(target.address).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + }); + } + }, + }; +} + +const fakeDesktop = Effect.fn(function* ( + input: Omit[0], "platform" | "userId">, +) { + const platform = yield* HostProcessPlatform; + const userId = yield* HostProcessUserId; + return yield* Effect.acquireRelease( + Effect.promise(() => startFakeDesktop({ ...input, platform, userId })), + (server) => Effect.promise(() => server.close()), + ); +}); + +const withTempDirectory = ( + prefix: string, + use: (root: string) => Effect.Effect, +) => + Effect.acquireUseRelease( + Effect.promise(() => NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), prefix))), + use, + (root) => Effect.promise(() => NodeFSP.rm(root, { recursive: true, force: true })), + ); + +describe("t3 app", () => { + it.effect("rejects SSH before it tries to reach a desktop app", () => + withTempDirectory("t3-app-ssh-test-", (root) => + Effect.gen(function* () { + const baseDir = NodePath.join(root, "missing-t3-home"); + const error = yield* runCli(["app", "--base-dir", baseDir], { + SSH_CONNECTION: "client server", + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "DesktopAppSshUnsupportedError", + message: + "`t3 app` only controls a desktop app on the same machine. It cannot run over SSH.", + }); + expect(yield* pathExists(baseDir)).toBe(false); + }), + ), + ); + + it.effect("rejects unsupported platforms without creating state", () => + withTempDirectory("t3-app-platform-test-", (root) => + Effect.gen(function* () { + const baseDir = NodePath.join(root, "missing-t3-home"); + const error = yield* runCli(["app", "--base-dir", baseDir]).pipe( + Effect.provideService(HostProcessPlatform, "freebsd"), + Effect.flip, + ); + + expect(error).toMatchObject({ + _tag: "DesktopAppPlatformUnsupportedError", + platform: "freebsd", + message: "`t3 app` is not supported on freebsd.", + }); + expect(yield* pathExists(baseDir)).toBe(false); + }), + ), + ); + + it.effect("does not create state when only a server or no desktop app is running", () => + withTempDirectory("t3-app-missing-test-", (root) => + Effect.gen(function* () { + const baseDir = NodePath.join(root, "missing-t3-home"); + const error = yield* runCli(["app", "--base-dir", baseDir]).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "DesktopAppUnreachableError", + candidateAddresses: [expect.any(String)], + workspaceRoot: yield* HostProcessWorkingDirectory, + message: expect.stringContaining("Could not reach the T3 Code desktop app."), + cause: { code: "ENOENT" }, + }); + expect(yield* pathExists(baseDir)).toBe(false); + }), + ), + ); + + it.effect("uses T3CODE_HOME or --base-dir and sends the default or explicit path", () => + withTempDirectory("t3-app-command-test-", (root) => + Effect.gen(function* () { + const baseDir = NodePath.join(root, "t3-home"); + const explicitPath = NodePath.join(root, "project"); + const platform = yield* HostProcessPlatform; + const workingDirectory = yield* HostProcessWorkingDirectory; + const desktop = yield* fakeDesktop({ baseDir }); + + yield* runCli(["app"], { T3CODE_HOME: baseDir }); + yield* runCli(["app", explicitPath, "--base-dir", baseDir]); + + expect(desktop.received.map((request) => request.workspaceRoot)).toEqual([ + workingDirectory, + explicitPath, + ]); + expect(desktop.received.every((request) => request.platform === platform)).toBe(true); + }).pipe(Effect.scoped), + ), + ); + + it.effect("prefers the installed desktop app when a dev desktop is also running", () => + withTempDirectory("t3-app-preferred-test-", (root) => + Effect.gen(function* () { + vi.mocked(NodeOS.homedir).mockReturnValue(root); + const baseDir = NodePath.join(root, defaultStateHomeName); + const desktop = yield* fakeDesktop({ baseDir }); + const development = yield* fakeDesktop({ baseDir, stateSubdirectory: "dev" }); + + yield* runCli(["app"]); + + expect(desktop.received).toHaveLength(1); + expect(development.received).toHaveLength(0); + }).pipe(Effect.scoped), + ), + ); + + it.effect("finds the dev desktop when the default desktop socket is absent", () => + withTempDirectory("t3-app-dev-test-", (root) => + Effect.gen(function* () { + vi.mocked(NodeOS.homedir).mockReturnValue(root); + const baseDir = NodePath.join(root, defaultStateHomeName); + const development = yield* fakeDesktop({ baseDir, stateSubdirectory: "dev" }); + + yield* runCli(["app"]); + yield* runCli(["app"], { T3CODE_HOME: " " }); + + expect(development.received).toHaveLength(2); + expect(yield* pathExists(baseDir)).toBe(false); + }).pipe(Effect.scoped), + ), + ); + + it.effect("never searches a dev state directory for an explicit T3 home", () => + withTempDirectory("t3-app-explicit-test-", (root) => + Effect.gen(function* () { + vi.mocked(NodeOS.homedir).mockReturnValue(root); + const baseDir = NodePath.join(root, defaultStateHomeName); + const development = yield* fakeDesktop({ baseDir, stateSubdirectory: "dev" }); + + const flagError = yield* runCli(["app", "--base-dir", baseDir]).pipe(Effect.flip); + const envError = yield* runCli(["app"], { T3CODE_HOME: baseDir }).pipe(Effect.flip); + + expect(flagError).toMatchObject({ _tag: "DesktopAppUnreachableError" }); + expect(envError).toMatchObject({ _tag: "DesktopAppUnreachableError" }); + expect(development.received).toHaveLength(0); + }).pipe(Effect.scoped), + ), + ); + + for (const responseKind of ["failure", "invalid"] as const) { + it.effect(`never falls back after the default desktop sends a ${responseKind} response`, () => + withTempDirectory("t3-app-response-test-", (root) => + Effect.gen(function* () { + vi.mocked(NodeOS.homedir).mockReturnValue(root); + const baseDir = NodePath.join(root, defaultStateHomeName); + const desktop = yield* fakeDesktop({ + baseDir, + reply: (request) => + responseKind === "failure" + ? { + version: 1, + requestId: request.requestId, + ok: false, + code: "project-create-failed", + message: "The project path is not available.", + } + : { invalid: true }, + }); + const development = yield* fakeDesktop({ baseDir, stateSubdirectory: "dev" }); + + const error = yield* runCli(["app"]).pipe(Effect.flip); + + expect(desktop.received).toHaveLength(1); + expect(development.received).toHaveLength(0); + if (responseKind === "failure") { + expect(error).toMatchObject({ + _tag: "DesktopAppRequestFailedError", + code: "project-create-failed", + requestId: desktop.received[0]?.requestId, + workspaceRoot: yield* HostProcessWorkingDirectory, + message: expect.stringContaining("project-create-failed"), + cause: { + ok: false, + code: "project-create-failed", + message: "The project path is not available.", + }, + }); + } else { + expect(error).toMatchObject({ + _tag: "DesktopAppUnreachableError", + cause: { message: "The desktop app response is invalid." }, + }); + } + }).pipe(Effect.scoped), + ), + ); + } +}); diff --git a/apps/server/src/cli/app.ts b/apps/server/src/cli/app.ts new file mode 100644 index 00000000000..85fbebd7447 --- /dev/null +++ b/apps/server/src/cli/app.ts @@ -0,0 +1,261 @@ +// @effect-diagnostics globalTimers:off -- The Node socket client owns its response deadline and clears it on every completion path. +import * as NodeCrypto from "node:crypto"; +import * as NodeNet from "node:net"; +import * as NodeOS from "node:os"; + +import { + DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION, + DesktopAppActivationErrorCode, + DesktopAppActivationResponse, + type DesktopAppActivationPlatform, + type DesktopAppActivationRequest, +} from "@t3tools/contracts"; +import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl"; +import { + HostProcessPlatform, + HostProcessUserId, + HostProcessWorkingDirectory, +} from "@t3tools/shared/hostProcess"; +import * as Config from "effect/Config"; +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { Argument, Command } from "effect/unstable/cli"; + +import { expandHomePath, resolveBaseDir } from "../os-jank.ts"; +import { baseDirFlag } from "./config.ts"; + +const CLI_RESPONSE_TIMEOUT_MS = 17_000; +const MAX_RESPONSE_BYTES = 64 * 1024; +const isDesktopAppActivationResponse = Schema.is(DesktopAppActivationResponse); + +export class DesktopAppSshUnsupportedError extends Schema.TaggedErrorClass()( + "DesktopAppSshUnsupportedError", + {}, +) { + override get message(): string { + return "`t3 app` only controls a desktop app on the same machine. It cannot run over SSH."; + } +} + +export class DesktopAppPlatformUnsupportedError extends Schema.TaggedErrorClass()( + "DesktopAppPlatformUnsupportedError", + { platform: Schema.String }, +) { + override get message(): string { + return `\`t3 app\` is not supported on ${this.platform}.`; + } +} + +export class DesktopAppUnreachableError extends Schema.TaggedErrorClass()( + "DesktopAppUnreachableError", + { + candidateAddresses: Schema.Array(Schema.String), + requestId: Schema.String, + workspaceRoot: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Could not reach the T3 Code desktop app. Start or update the desktop app on this machine, then run `t3 app` again. A running T3 Code server is not enough."; + } +} + +export class DesktopAppRequestFailedError extends Schema.TaggedErrorClass()( + "DesktopAppRequestFailedError", + { + code: DesktopAppActivationErrorCode, + requestId: Schema.String, + workspaceRoot: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `T3 Code could not open ${this.workspaceRoot} (${this.code}).`; + } +} + +function isDesktopPlatform(platform: NodeJS.Platform): platform is DesktopAppActivationPlatform { + return platform === "darwin" || platform === "linux" || platform === "win32"; +} + +export function sendDesktopAppActivationRequest(input: { + readonly address: string; + readonly fallbackAddress?: string; + readonly request: DesktopAppActivationRequest; + readonly timeoutMs?: number; +}): Promise { + return new Promise((resolve, reject) => { + const socket = NodeNet.createConnection(input.address); + socket.setEncoding("utf8"); + let buffer = ""; + let settled = false; + let connected = false; + + const finish = ( + result: + | { readonly type: "success"; readonly response: DesktopAppActivationResponse } + | { readonly type: "failure"; readonly error: Error }, + ) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + socket.destroy(); + if (result.type === "success") resolve(result.response); + else reject(result.error); + }; + + const timeout = setTimeout(() => { + finish({ + type: "failure", + error: new Error("The desktop app did not respond in time."), + }); + }, input.timeoutMs ?? CLI_RESPONSE_TIMEOUT_MS); + + socket.once("connect", () => { + connected = true; + socket.write(`${JSON.stringify(input.request)}\n`); + }); + socket.on("data", (chunk) => { + buffer += chunk; + if (Buffer.byteLength(buffer, "utf8") > MAX_RESPONSE_BYTES) { + finish({ type: "failure", error: new Error("The desktop app response is too large.") }); + return; + } + const newline = buffer.indexOf("\n"); + if (newline === -1) return; + + let parsed: unknown; + try { + parsed = JSON.parse(buffer.slice(0, newline)); + } catch { + finish({ + type: "failure", + error: new Error("The desktop app response is not valid JSON."), + }); + return; + } + if (!isDesktopAppActivationResponse(parsed)) { + finish({ type: "failure", error: new Error("The desktop app response is invalid.") }); + return; + } + if (parsed.requestId !== input.request.requestId) { + finish({ + type: "failure", + error: new Error("The desktop app response did not match this request."), + }); + return; + } + finish({ type: "success", response: parsed }); + }); + socket.once("error", (error: NodeJS.ErrnoException) => { + if ( + !settled && + !connected && + input.fallbackAddress !== undefined && + (error.code === "ENOENT" || error.code === "ECONNREFUSED") + ) { + settled = true; + clearTimeout(timeout); + socket.destroy(); + resolve( + sendDesktopAppActivationRequest({ + address: input.fallbackAddress, + request: input.request, + ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }), + }), + ); + return; + } + finish({ type: "failure", error }); + }); + socket.once("end", () => { + finish({ type: "failure", error: new Error("The desktop app closed the connection.") }); + }); + }); +} + +const appEnvironment = Config.all({ + t3Home: Config.string("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)), + sshConnection: Config.string("SSH_CONNECTION").pipe(Config.option), + sshTty: Config.string("SSH_TTY").pipe(Config.option), +}); + +const runAppCommand = Effect.fn("cli.app")(function* (flags: { + readonly baseDir: Option.Option; + readonly workspaceRoot: Option.Option; +}) { + const environment = yield* appEnvironment; + const hostPlatform = yield* HostProcessPlatform; + if (Option.isSome(environment.sshConnection) || Option.isSome(environment.sshTty)) { + return yield* new DesktopAppSshUnsupportedError({}); + } + if (!isDesktopPlatform(hostPlatform)) { + return yield* new DesktopAppPlatformUnsupportedError({ platform: hostPlatform }); + } + + const path = yield* Path.Path; + const configuredBaseDir = Option.getOrUndefined(flags.baseDir) ?? environment.t3Home; + const baseDir = yield* resolveBaseDir(configuredBaseDir); + const allowDevFallback = Option.isNone(flags.baseDir) && !environment.t3Home?.trim(); + const rawWorkspaceRoot = + Option.getOrUndefined(flags.workspaceRoot) ?? (yield* HostProcessWorkingDirectory); + const workspaceRoot = path.resolve(yield* expandHomePath(rawWorkspaceRoot)); + const userId = yield* HostProcessUserId; + const resolveAddress = (stateSubdirectory: "userdata" | "dev") => + resolveDesktopAppControlAddress({ + stateDir: path.join(baseDir, stateSubdirectory), + platform: hostPlatform, + tempDir: NodeOS.tmpdir(), + userId, + joinPath: path.join, + }).address; + const request: DesktopAppActivationRequest = { + version: DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION, + requestId: NodeCrypto.randomUUID(), + type: "open-workspace", + workspaceRoot, + platform: hostPlatform, + }; + const address = resolveAddress("userdata"); + const fallbackAddress = allowDevFallback ? resolveAddress("dev") : undefined; + + const response = yield* Effect.tryPromise({ + try: () => + sendDesktopAppActivationRequest({ + address, + ...(fallbackAddress === undefined ? {} : { fallbackAddress }), + request, + }), + catch: (cause) => + new DesktopAppUnreachableError({ + candidateAddresses: fallbackAddress === undefined ? [address] : [address, fallbackAddress], + requestId: request.requestId, + workspaceRoot, + cause, + }), + }); + if (!response.ok) { + return yield* new DesktopAppRequestFailedError({ + code: response.code, + requestId: response.requestId, + workspaceRoot, + cause: response, + }); + } + + yield* Console.log(`Opened ${workspaceRoot} in T3 Code.`); +}); + +export const appCommand = Command.make("app", { + baseDir: baseDirFlag, + workspaceRoot: Argument.string("path").pipe( + Argument.withDescription("Project directory. Default: current directory."), + Argument.optional, + ), +}).pipe( + Command.withDescription("Open a project in the running T3 Code desktop app."), + Command.withHandler(runAppCommand), +); diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 7ae036bdc99..9d54adef8ed 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -1,5 +1,5 @@ import { expect, it } from "@effect/vitest"; -import { describe } from "vite-plus/test"; +import { describe, vi } from "vite-plus/test"; import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; @@ -7,6 +7,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import { HttpServerResponse } from "effect/unstable/http"; +import { openMediaFile } from "./assets/MediaFile.ts"; import { assetResponseHeaders, @@ -19,6 +20,179 @@ import { const fileResponseLayer = Layer.mergeAll(NodeHttpPlatform.layer, NodeServices.layer); describe("video asset byte ranges", () => { + it.effect("uses current descriptor metadata after an in-place truncate or extension", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-current-stat-" }); + const filePath = path.join(directory, "clip.mp4"); + for (const [contents, range, method, expected, status, contentRange] of [ + ["1234", undefined, "GET", "1234", 200, null], + ["0123456789abcdef", undefined, "GET", "0123456789abcdef", 200, null], + ["1234", "bytes=4-", "GET", "", 416, "bytes */4"], + ["1234", "bytes=1-20", "GET", "234", 206, "bytes 1-3/4"], + ["0123456789abcdef", "bytes=10-", "GET", "abcdef", 206, "bytes 10-15/16"], + ["0123456789abcdef", undefined, "HEAD", "", 200, null], + ["", undefined, "GET", "", 200, null], + ["", "bytes=0-1", "GET", "", 416, "bytes */0"], + ] as const) { + yield* fs.writeFileString(filePath, "0123456789"); + const canonicalPath = yield* fs.realPath(filePath); + const file = yield* openMediaFile(canonicalPath); + if (!file) throw new Error("Expected an opened media file"); + yield* fs.writeFileString(filePath, contents); + const response = HttpServerResponse.toWeb( + yield* assetFileResponse( + { path: canonicalPath, file, mimeType: "video/mp4" }, + range, + undefined, + method, + ), + ); + expect(response.status).toBe(status); + expect(response.headers.get("content-range")).toBe(contentRange); + if (status !== 416) { + expect(response.headers.get("content-length")).toBe( + String(method === "HEAD" ? contents.length : expected.length), + ); + } + expect(yield* Effect.promise(() => response.text())).toBe(expected); + } + }).pipe(Effect.provide(fileResponseLayer)), + ); + + it.effect( + "rejects unaddressable ranges before streaming and preserves small ranges on large files", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-offset-limit-" }); + const filePath = path.join(directory, "clip.mp4"); + yield* fs.writeFileString(filePath, "0123456789"); + const canonicalPath = yield* fs.realPath(filePath); + const unsafeOffset = BigInt(Number.MAX_SAFE_INTEGER) + 1n; + const size = unsafeOffset + 32n; + for (const [range, status] of [ + [`bytes=${unsafeOffset}-${unsafeOffset}`, 416], + [`bytes=0-${unsafeOffset}`, 416], + ["bytes=-1", 416], + [undefined, 413], + ["bytes=0-1", 206], + ] as const) { + const file = yield* openMediaFile(canonicalPath); + if (!file) throw new Error("Expected an opened media file"); + // Model a sparse file beyond the native stream's numeric addressing limit. + const info = yield* Effect.promise(() => file.handle.stat({ bigint: true })); + info.size = size; + const statSpy = vi.spyOn(file.handle, "stat").mockResolvedValue(info); + yield* Effect.addFinalizer(() => Effect.sync(() => statSpy.mockRestore())); + const response = HttpServerResponse.toWeb( + yield* assetFileResponse({ path: canonicalPath, file, mimeType: "video/mp4" }, range), + ); + expect(response.status).toBe(status); + if (status === 416) { + expect(response.headers.get("content-range")).toBe(`bytes */${size}`); + expect(yield* Effect.promise(() => response.text())).toBe(""); + } else if (status === 206) { + expect(response.headers.get("content-range")).toBe(`bytes 0-1/${size}`); + expect(yield* Effect.promise(() => response.text())).toBe("01"); + } else { + expect(yield* Effect.promise(() => response.text())).toBe( + "File is too large to preview.", + ); + } + } + }).pipe(Effect.provide(fileResponseLayer)), + ); + + it.effect("streams guarded file ranges, including suffixes and conditional requests", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-range-" }); + const filePath = path.join(directory, "clip.mp4"); + yield* fs.writeFileString(filePath, "0123456789"); + const canonicalPath = yield* fs.realPath(filePath); + for (const [range, ifRange, expected, status, contentRange] of [ + [undefined, undefined, "0123456789", 200, null], + ["bytes=0-1", undefined, "01", 206, "bytes 0-1/10"], + ["bytes=4-", undefined, "456789", 206, "bytes 4-9/10"], + ["bytes=-3", undefined, "789", 206, "bytes 7-9/10"], + ["bytes=-999999999999999999999999", undefined, "0123456789", 206, "bytes 0-9/10"], + ["bytes=10-", undefined, "", 416, "bytes */10"], + ["bytes=0-1", '"old-etag"', "0123456789", 200, null], + ["bytes=0-1", "", "0123456789", 200, null], + ] as const) { + const file = yield* openMediaFile(canonicalPath); + if (!file) throw new Error("Expected an opened media file"); + const response = HttpServerResponse.toWeb( + yield* assetFileResponse( + { path: canonicalPath, file, mimeType: "video/mp4" }, + range, + ifRange, + ), + ); + expect(response.status).toBe(status); + expect(response.headers.get("accept-ranges")).toBe("bytes"); + expect(response.headers.get("content-range")).toBe(contentRange); + expect(response.headers.get("cache-control")).toBe("private, no-store"); + expect(response.headers.get("etag")).toBeNull(); + expect(response.headers.get("last-modified")).toBeNull(); + if (status !== 416) + expect(response.headers.get("content-length")).toBe(String(expected.length)); + expect(yield* Effect.promise(() => response.text())).toBe(expected); + } + }).pipe(Effect.provide(fileResponseLayer)), + ); + + it.effect("closes guarded descriptors after full, HEAD, rejected, and cancelled responses", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-cleanup-" }); + const filePath = path.join(directory, "clip.mp4"); + const bytes = new Uint8Array(1024 * 1024).fill(42); + yield* fs.writeFile(filePath, bytes); + const canonicalPath = yield* fs.realPath(filePath); + for (const mode of ["full", "HEAD", "rejected", "cancelled"] as const) { + const file = yield* Effect.scoped( + Effect.gen(function* () { + const file = yield* openMediaFile(canonicalPath); + if (!file) throw new Error("Expected an opened media file"); + const response = HttpServerResponse.toWeb( + yield* assetFileResponse( + { path: canonicalPath, file, mimeType: "video/mp4" }, + mode === "rejected" ? `bytes=${bytes.length}-` : "bytes=0-", + undefined, + mode === "HEAD" ? "HEAD" : "GET", + ), + ); + if (mode === "HEAD") { + expect(response.status).toBe(200); + expect(response.headers.get("content-length")).toBe(String(bytes.length)); + expect(response.headers.get("content-range")).toBeNull(); + expect(yield* Effect.promise(() => response.text())).toBe(""); + } else if (mode === "rejected") { + expect(response.status).toBe(416); + expect(yield* Effect.promise(() => response.text())).toBe(""); + } else if (mode === "cancelled") { + const reader = response.body!.getReader(); + const first = yield* Effect.promise(() => reader.read()); + expect(first.done).toBe(false); + expect(first.value!.byteLength).toBeLessThan(bytes.length); + yield* Effect.promise(() => reader.cancel()); + } else { + expect(yield* Effect.promise(() => response.arrayBuffer())).toEqual(bytes.buffer); + } + return file; + }), + ); + expect(file.handle.fd).toBe(-1); + } + }).pipe(Effect.provide(fileResponseLayer)), + ); + it.effect("streams exactly the requested bytes and leaves full downloads intact", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index b83461775e9..8b7c2ad3a61 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -29,6 +29,7 @@ import { OtlpTracer } from "effect/unstable/observability"; import * as ServerConfig from "./config.ts"; import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts"; +import { statMediaFile, streamMediaFile, type OpenMediaFile } from "./assets/MediaFile.ts"; import { ATTACHMENT_UPLOAD_ROUTE_PREFIX, storeAttachmentUpload, @@ -124,6 +125,9 @@ function assetByteRange(header: string, size: bigint) { } const start = first ?? (last! >= size ? 0n : size - last!); const end = first === null || last === null || last >= size ? size - 1n : last; + if (!Number.isSafeInteger(Number(start)) || !Number.isSafeInteger(Number(end))) { + return { _tag: "Unsatisfiable" as const }; + } return { _tag: "Range" as const, offset: start, @@ -138,17 +142,30 @@ export const assetFileResponse = Effect.fn("assetFileResponse")(function* ( readonly download?: boolean; readonly fileName?: string; readonly mimeType?: string; + readonly file?: OpenMediaFile; }, rangeHeader?: string, ifRangeHeader?: string, + method: "GET" | "HEAD" = "GET", ) { const headers = assetResponseHeaders(asset.path, asset); - if (headers["Content-Type"]?.toLowerCase().startsWith("video/")) { + const mediaFile = asset.file; + const mediaInfo = mediaFile ? yield* statMediaFile(asset.path, mediaFile) : undefined; + const isVideo = headers["Content-Type"]?.toLowerCase().startsWith("video/") === true; + if (mediaFile && isVideo) { + // Host videos can change in place. Do not invite conditional range requests + // with validators that cannot establish byte-for-byte identity. + headers["Cache-Control"] = "private, no-store"; + } + let status = 200; + let offset = 0n; + let bytesToRead: bigint | undefined; + if (isVideo) { headers["Accept-Ranges"] = "bytes"; // If-Range requires a matching validator. A full response is safe when we cannot validate it. - if (rangeHeader && !ifRangeHeader) { + if (method === "GET" && rangeHeader && ifRangeHeader === undefined) { const fs = yield* FileSystem.FileSystem; - const info = yield* fs.stat(asset.path); + const info = mediaInfo ?? (yield* fs.stat(asset.path)); const range = assetByteRange(rangeHeader, info.size); if (range?._tag === "Unsatisfiable") { return HttpServerResponse.empty({ @@ -157,16 +174,34 @@ export const assetFileResponse = Effect.fn("assetFileResponse")(function* ( }); } if (range?._tag === "Range") { - return yield* HttpServerResponse.file(asset.path, { - status: 206, - offset: range.offset, - bytesToRead: range.bytesToRead, - headers: { ...headers, "Content-Range": range.contentRange }, - }); + status = 206; + offset = range.offset; + bytesToRead = range.bytesToRead; + headers["Content-Range"] = range.contentRange; } } } - return yield* HttpServerResponse.file(asset.path, { status: 200, headers }); + if (mediaFile && mediaInfo) { + const size = bytesToRead ?? mediaInfo.size; + headers["Content-Type"] ??= Mime.getType(asset.path) ?? "application/octet-stream"; + headers["Content-Length"] = String(size); + if (!isVideo) { + headers["Last-Modified"] = mediaInfo.mtime.toUTCString(); + headers.ETag = `W/"${mediaInfo.size.toString(16)}-${mediaInfo.mtimeMs.toString(16)}"`; + } + if (method === "HEAD" || size === 0n) { + return HttpServerResponse.empty({ status, headers }); + } + const body = streamMediaFile(mediaFile, offset, size); + if (!body) { + return HttpServerResponse.text("File is too large to preview.", { status: 413 }); + } + return HttpServerResponse.stream(body, { + status, + headers, + }); + } + return yield* HttpServerResponse.file(asset.path, { status, offset, bytesToRead, headers }); }); export const httpCompressionLayer = HttpRouter.middleware(HttpMiddleware.compression(), { @@ -338,6 +373,7 @@ export const assetRouteLayer = HttpRouter.add( asset, request.method === "GET" ? request.headers.range : undefined, request.headers["if-range"], + request.method === "HEAD" ? "HEAD" : "GET", ).pipe( Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })), ); diff --git a/apps/web/package.json b/apps/web/package.json index 4e4fbaad970..1ef6b56d3f7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.50", + "version": "0.0.51", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts index f8c0b5ae75f..5c642471404 100644 --- a/apps/web/src/assets/assetUrls.ts +++ b/apps/web/src/assets/assetUrls.ts @@ -1,11 +1,13 @@ import { useAtomValue } from "@effect/atom-react"; import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; import { AsyncResult } from "effect/unstable/reactivity"; -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { assetEnvironment } from "~/state/assets"; import { usePreparedConnection } from "~/state/session"; +import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; @@ -49,6 +51,21 @@ export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResourc return result.url; } +/** Re-mints an exact-file capability after a file change or an explicit retry. */ +export function useAssetUrlRefresh( + environmentId: EnvironmentId, + resource: AssetResource, +): () => Promise { + const refresh = useAtomQueryRunner(assetEnvironment.createUrl, { + reportFailure: false, + refresh: true, + }); + return useCallback(async () => { + const result = await refresh({ environmentId, input: { resource } }); + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + }, [environmentId, resource, refresh]); +} + export function useAssetUrls( environmentId: EnvironmentId, resources: ReadonlyArray, diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 9a413c95869..78e1d089950 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -42,6 +42,9 @@ import { classifyMarkdownImageSource, markdownImageSourceFragment, } from "@t3tools/client-runtime/markdown-images"; +import { inlineCodeFilePathCandidate } from "@t3tools/client-runtime/markdown-links"; +import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; +import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import React, { @@ -76,7 +79,14 @@ import { renderCodexFileCitationsAsMarkdown, } from "@t3tools/client-runtime/codex-markdown-directives"; import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; -import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; +import { + resolveMarkdownMediaPreview, + type ExpandedImagePreview, +} from "./chat/ExpandedImagePreview"; +import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; +import { MediaVideoPlayer } from "./media/MediaVideoPlayer"; +import { MediaActions, type MediaActionSource } from "./media/MediaActions"; +import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent"; import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; import { @@ -128,7 +138,7 @@ import { type MarkdownFileLinkMeta, } from "../markdown-links"; import { readLocalApi } from "../localApi"; -import { useAssetUrlState } from "../assets/assetUrls"; +import { useAssetUrlRefresh, useAssetUrlState } from "../assets/assetUrls"; import { cn } from "../lib/utils"; import { useRemoteOpenResolution, type RemoteOpenMode } from "../remoteOpen"; import { useRightPanelStore } from "../rightPanelStore"; @@ -197,8 +207,14 @@ export function hasMarkdownFilePrimaryAction(input: { canOpenInEditor: boolean; canOpenInBrowser: boolean; canOpenInPanel: boolean; + canOpenMedia?: boolean; }): boolean { - return input.canOpenInEditor || input.canOpenInBrowser || input.canOpenInPanel; + return ( + input.canOpenInEditor || + input.canOpenInBrowser || + input.canOpenInPanel || + input.canOpenMedia === true + ); } export function shouldUseMarkdownFileBrowserPrimaryAction(input: { @@ -1016,6 +1032,7 @@ interface MarkdownFileLinkProps { onOpenInPanel: (workspaceRelativePath: string, line: number | undefined) => void; openInEditorMenuLabel: string; onOpenInBrowser?: (() => Promise>) | undefined; + onOpenMedia?: (() => void) | undefined; onReveal?: (() => Promise>) | undefined; /** Platform-specific menu label ("Reveal in Finder", ...); required for the reveal item to show. */ @@ -1138,10 +1155,16 @@ const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: ); }); -const CHAT_MARKDOWN_IMAGE_BOUNDS_CLASS_NAME = "max-h-[30rem] max-w-[min(100%,30rem)]"; +const CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME = "max-w-[min(100%,30rem)]"; +const CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME = cn( + "max-h-[30rem]", + CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME, +); +const CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME = "inline-block!"; +const CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME = "rounded-lg border border-border/40"; const CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME = cn( "h-auto w-auto object-contain", - CHAT_MARKDOWN_IMAGE_BOUNDS_CLASS_NAME, + CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME, ); function markdownImageCopy(alt: string, src: string, title: string | undefined): string { @@ -1172,11 +1195,10 @@ function authoredImageSizeStyle( return undefined; } -const CHAT_MARKDOWN_WORKSPACE_IMAGE_LAYOUT_CLASS_NAME = "inline-block!"; const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn( CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME, - CHAT_MARKDOWN_WORKSPACE_IMAGE_LAYOUT_CLASS_NAME, - "rounded-lg border border-border/40", + CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME, + CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME, ); const MarkdownLinkContext = React.createContext(false); @@ -1184,6 +1206,8 @@ function expandableMarkdownImageProps( onImageExpand: ((preview: ExpandedImagePreview) => void) | undefined, src: string, alt: string, + originalUrl?: string, + actionsSource?: MediaActionSource, ) { if (!onImageExpand) return {}; const previewName = alt.trim() || "image"; @@ -1191,7 +1215,17 @@ function expandableMarkdownImageProps( if (event.currentTarget.closest("a")) return; event.preventDefault(); event.stopPropagation(); - onImageExpand({ images: [{ src, name: previewName }], index: 0 }); + onImageExpand({ + images: [ + { + src, + name: previewName, + ...(originalUrl ? { originalUrl } : {}), + ...(actionsSource ? { actionsSource } : {}), + }, + ], + index: 0, + }); }; return { role: "button" as const, @@ -1207,70 +1241,201 @@ function expandableMarkdownImageProps( function ChatMarkdownImageFallback(props: { readonly alt: string; readonly copyMarkdown?: string | undefined; + readonly kind?: "image" | "video"; + readonly actionsSource?: MediaActionSource; }) { - return ( + const label = props.kind === "video" ? "Video unavailable" : "Image unavailable"; + const content = ( - {props.alt.length > 0 ? `Image unavailable · ${props.alt}` : "Image unavailable"} + {props.alt.length > 0 ? `${label} · ${props.alt}` : label} ); + return props.actionsSource ? ( + {content} + ) : ( + content + ); +} + +function ChatMarkdownVideo(props: { + readonly src: string | null; + readonly alt: string; + readonly copyMarkdown: string | undefined; + readonly originalUrl?: string | undefined; + readonly sourceFailed?: boolean | undefined; + readonly style?: CSSProperties | undefined; + readonly mediaIdentity?: string | undefined; + readonly actionsSource?: MediaActionSource | undefined; + readonly onRetry?: (() => Promise) | undefined; + readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; +}) { + return ( + { + props.onImageExpand?.({ + images: [ + { + src, + name: props.alt || "video", + type: "video", + autoPlay: false, + ...(props.originalUrl ? { originalUrl: props.originalUrl } : {}), + ...(props.actionsSource + ? { actionsSource: { ...props.actionsSource, src } } + : {}), + }, + ], + index: 0, + }); + } + : undefined + } + /> + ); } -/** Environment-hosted images load through a signed asset URL. */ +/** Environment-hosted media loads through an exact-file signed asset URL. */ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props: { readonly environmentId: EnvironmentId; - readonly resource: Extract; + readonly resource: Extract< + AssetResource, + { readonly _tag: "attachment" | "workspace-file" | "media-file" } + >; + readonly kind?: "image" | "video"; readonly alt: string; readonly copyMarkdown?: string; readonly srcFragment?: string; readonly style?: CSSProperties | undefined; + readonly workspaceRoot?: string | undefined; readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; }) { const assetUrl = useAssetUrlState(props.environmentId, props.resource); + const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, props.resource); const [failedUrl, setFailedUrl] = useState(null); + const resource = props.resource; + const path = + resource._tag === "media-file" + ? resource.path + : resource._tag === "workspace-file" && props.workspaceRoot + ? `${props.workspaceRoot.replace(/[\\/]+$/, "")}/${resource.path}` + : undefined; + const reference = path ? mediaFileReference(path, props.workspaceRoot) : undefined; + const relativePath = reference?.relativePath; + const src = assetUrl._tag === "Success" ? assetUrl.url + (props.srcFragment ?? "") : null; + const actionsSource: MediaActionSource = { + kind: props.kind ?? "image", + name: props.alt || (props.kind ?? "image"), + src, + asset: { environmentId: props.environmentId, resource }, + ...(reference ? { reference } : {}), + ...(relativePath && resource._tag !== "attachment" + ? { + onOpenFile: () => + useRightPanelStore + .getState() + .openFile( + { environmentId: props.environmentId, threadId: resource.threadId }, + relativePath, + ), + } + : {}), + }; + + if (props.kind === "video") { + return ( + + ); + } if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) { - return ; + return ( + + ); } if (assetUrl._tag !== "Success") { return ( - + + + ); + } + return ( + + {props.alt} setFailedUrl(assetUrl.url)} /> - ); - } - const src = assetUrl.url + (props.srcFragment ?? ""); - return ( - {props.alt} setFailedUrl(assetUrl.url)} - /> + ); }); @@ -1454,6 +1619,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ onOpenInPanel, openInEditorMenuLabel, onOpenInBrowser, + onOpenMedia, onReveal, revealLabel, className, @@ -1497,12 +1663,16 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ }, [onOpen, targetPath]); const handleOpenInFilePreview = useCallback(() => { - if (!threadRef || !workspaceRelativePath) { - handleOpenInEditor(); + if (threadRef && workspaceRelativePath) { + onOpenInPanel(workspaceRelativePath, line); return; } - onOpenInPanel(workspaceRelativePath, line); - }, [handleOpenInEditor, line, onOpenInPanel, threadRef, workspaceRelativePath]); + if (onOpenMedia) { + onOpenMedia(); + return; + } + handleOpenInEditor(); + }, [handleOpenInEditor, line, onOpenInPanel, onOpenMedia, threadRef, workspaceRelativePath]); const handleOpenWithSystemDefault = useCallback(() => { if (!onOpenWithSystemDefault) return; @@ -1687,6 +1857,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ try { const clicked = await api.contextMenu.show( [ + ...(onOpenMedia ? ([{ id: "preview-media", label: "Preview media" }] as const) : []), ...(onOpen ? ([{ id: "open", label: openInEditorMenuLabel }] as const) : []), ...(onOpenInBrowser ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) @@ -1698,6 +1869,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ position, ); + if (clicked === "preview-media") { + onOpenMedia?.(); + return; + } if (clicked === "open") { handleOpenInEditor(); return; @@ -1731,6 +1906,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ handleOpenInEditor, handleRevealInFileManager, onOpenInBrowser, + onOpenMedia, onOpen, onReveal, openInEditorMenuLabel, @@ -1762,6 +1938,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ canOpenInEditor, canOpenInBrowser, canOpenInPanel, + canOpenMedia: onOpenMedia !== undefined, }); const useBrowserPrimaryAction = shouldUseMarkdownFileBrowserPrimaryAction({ iconPath, @@ -1854,6 +2031,7 @@ function areMarkdownFileLinkPropsEqual( previous.onOpenInPanel === next.onOpenInPanel && previous.openInEditorMenuLabel === next.openInEditorMenuLabel && previous.onOpenInBrowser === next.onOpenInBrowser && + previous.onOpenMedia === next.onOpenMedia && previous.onReveal === next.onReveal && previous.revealLabel === next.revealLabel && previous.className === next.className @@ -1877,8 +2055,18 @@ function ChatMarkdown({ extraRemarkPlugins = EMPTY_REMARK_PLUGINS, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); + const [localMediaPreview, setLocalMediaPreview] = useState(null); + const expandMedia = onImageExpand ?? setLocalMediaPreview; + const mediaRequestId = useRef(0); + useEffect(() => { + setLocalMediaPreview(null); + return () => { + mediaRequestId.current += 1; + }; + }, [threadRef?.environmentId, threadRef?.threadId, explicitEnvironmentId, cwd, imageBaseDir]); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, + refresh: true, }); const searchProjectEntries = useAtomQueryRunner(projectEnvironment.searchEntries, { reportFailure: false, @@ -1897,6 +2085,41 @@ function ChatMarkdown({ remoteOpen.isResolved, ); const preparedConnection = usePreparedConnection(environmentId); + const openMarkdownMedia = useCallback( + (source: string, resolvedFilePath?: string) => { + const requestId = ++mediaRequestId.current; + void resolveMarkdownMediaPreview({ + source, + resolvedFilePath, + cwd, + threadRef, + httpBaseUrl: + preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : undefined, + createAssetUrl, + onOpenFile: threadRef + ? (path) => useRightPanelStore.getState().openFile(threadRef, path) + : undefined, + }).then( + (preview) => { + if (preview && mediaRequestId.current === requestId) expandMedia(preview); + }, + (error: unknown) => { + if (mediaRequestId.current !== requestId) return; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Media unavailable", + description: + error instanceof Error + ? error.message + : "The file could not be loaded. It may have been moved or deleted.", + }), + ); + }, + ); + }, + [createAssetUrl, cwd, expandMedia, preparedConnection, threadRef], + ); const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); const threadServerConfig = useAtomValue( serverEnvironment.configValueAtom(threadRef?.environmentId ?? environmentId), @@ -2156,6 +2379,7 @@ function ChatMarkdown({ fileLinkMeta: MarkdownFileLinkMeta, copyMarkdown: string, className?: string, + mediaSource?: string, ) => { const parentSuffix = fileLinkParentSuffixByPath.get( fileLinkMeta.filePath.replaceAll("\\", "/"), @@ -2169,6 +2393,11 @@ function ChatMarkdown({ `L${fileLinkMeta.line}${fileLinkMeta.column ? `:C${fileLinkMeta.column}` : ""}`, ); } + const mediaPath = mediaSource ?? fileLinkMeta.filePath; + const canPreviewMedia = + mediaMimeTypeFromExtension( + fileLinkMeta.basename.slice(fileLinkMeta.basename.lastIndexOf(".")), + ) !== null; return ( openMarkdownMedia(mediaPath, fileLinkMeta.filePath) + : undefined + } openInEditorMenuLabel={preferredEditorMenuLabel} onReveal={ canUseShellActions && revealInFileManagerLabel !== undefined @@ -2321,6 +2555,21 @@ function ChatMarkdown({ handleMarkdownFragmentClick(event, href); return; } + if ( + href && + faviconHost !== null && + mediaKindFromPath(href) !== null && + !event.defaultPrevented && + !event.metaKey && + !event.ctrlKey && + !event.shiftKey && + !event.altKey + ) { + event.preventDefault(); + event.stopPropagation(); + openMarkdownMedia(href); + return; + } // A link to a change request in a workspace project opens beside the // conversation instead of in a browser: it is the thing being talked about, and // the panel it opens offers the browser as one of its actions. Anything else is @@ -2412,6 +2661,7 @@ function ChatMarkdown({ fileLinkMeta, `[${fileLinkMeta.basename}](${normalizedHref})`, props.className, + normalizedHref, ); }, code({ node, children, className, ...props }) { @@ -2421,7 +2671,12 @@ function ChatMarkdown({ inlineCodeFileLinkMetaByText.get(codeText.trim()) ?? resolveInlineCodeFileLinkMeta(codeText, cwd); if (fileLinkMeta) { - return fileLinkChip(fileLinkMeta, `\`${codeText}\``); + return fileLinkChip( + fileLinkMeta, + `\`${codeText}\``, + undefined, + inlineCodeFilePathCandidate(codeText) ?? codeText.trim(), + ); } } return ( @@ -2431,7 +2686,7 @@ function ChatMarkdown({ ); }, img: function MarkdownImage({ node, title, src, alt, ...props }) { - const imageExpand = use(MarkdownLinkContext) ? undefined : onImageExpand; + const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia; const localSrc = node?.properties?.dataLocalSrc; const markdownTitle = node?.properties?.dataMarkdownTitle; const authoredSrc = typeof localSrc === "string" ? localSrc : src; @@ -2444,21 +2699,53 @@ function ChatMarkdown({ const copyMarkdown = markdownImageCopy(altText, srcString, authoredTitle); const authoredSizeStyle = authoredImageSizeStyle(props.width, props.height); const imageSource = classifyMarkdownImageSource(classifiedSrc, imageBaseDir ?? cwd); + const kind = mediaKindFromPath(classifiedSrc) ?? "image"; if (imageSource._tag === "Direct") { + const mediaSrc = resolveProtocolRelativeMediaUrl(imageSource.uri); + const originalUrl = + resolveExternalWebLinkHost(imageSource.uri) !== null ? imageSource.uri : undefined; + const reference = mediaUrlReference(imageSource.uri); + const actionsSource: MediaActionSource = { + kind, + name: altText || kind, + src: mediaSrc, + ...(reference ? { reference } : {}), + }; + if (kind === "video") { + return ( + + ); + } return ( - {altText} + + {altText} + ); } if (imageSource._tag === "WorkspaceFile" && threadRef) { @@ -2466,19 +2753,21 @@ function ChatMarkdown({ ); } - return ; + return ; }, table({ node: _node, ...props }) { return ; @@ -2533,6 +2822,8 @@ function ChatMarkdown({ onTaskListChange, onUseArtifactTemplate, onImageExpand, + expandMedia, + openMarkdownMedia, openFileInPanel, openInPreferredEditor, openChangeRequestLink, @@ -2578,6 +2869,12 @@ function ChatMarkdown({ > {text} + {localMediaPreview ? ( + setLocalMediaPreview(null)} + /> + ) : null} ); } diff --git a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx index 39ea214d43c..172793bacb0 100644 --- a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx +++ b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx @@ -9,6 +9,7 @@ const testState = vi.hoisted(() => ({ vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); vi.mock("../assets/assetUrls", () => ({ + useAssetUrlRefresh: () => vi.fn(), useAssetUrlState: (_environmentId: unknown, resource: unknown) => { testState.resources.push(resource); if (testState.assetState === "loading") return { _tag: "Loading" }; @@ -106,7 +107,7 @@ describe("ChatMarkdown workspace images", () => { expect(testState.resources).toEqual([ { - _tag: "workspace-file", + _tag: "media-file", threadId: threadRef.threadId, path: expectedPath, }, @@ -126,14 +127,14 @@ describe("ChatMarkdown workspace images", () => { expect(testState.resources).toEqual([ { - _tag: "workspace-file", + _tag: "media-file", threadId: threadRef.threadId, path: "C:\\Users\\shawn\\project\\.t3\\workspace-image.svg", }, - { _tag: "workspace-file", threadId: threadRef.threadId, path: imagePath }, - { _tag: "workspace-file", threadId: threadRef.threadId, path: imagePath }, + { _tag: "media-file", threadId: threadRef.threadId, path: imagePath }, + { _tag: "media-file", threadId: threadRef.threadId, path: imagePath }, { - _tag: "workspace-file", + _tag: "media-file", threadId: threadRef.threadId, path: "\\\\server\\share\\workspace-image.svg", }, @@ -149,7 +150,7 @@ describe("ChatMarkdown workspace images", () => { expect(testState.resources).toEqual([ { - _tag: "workspace-file", + _tag: "media-file", threadId: threadRef.threadId, path: "D:/screens/workspace-image.svg", }, diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 6e391ab79e9..64ad1ce7d78 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -22,7 +22,6 @@ import { dismissBranchMismatchForSession, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, getStartedThreadModelChangeBlockReason, - loadVideoPreviewUrl, isVideoPreviewRequestCurrent, hasEnvironmentReconnectWarningGraceElapsed, hasServerAcknowledgedLocalDispatch, @@ -44,23 +43,6 @@ import { shouldWriteThreadErrorToCurrentServerThread, } from "./ChatView.logic"; -describe("loadVideoPreviewUrl", () => { - it("loads video bytes into an object URL", async () => { - const objectUrl = await loadVideoPreviewUrl("data:video/mp4;base64,AA=="); - expect(objectUrl).toMatch(/^blob:/); - URL.revokeObjectURL(objectUrl); - }); - - it("stops loading when the preview request is cancelled", async () => { - const controller = new AbortController(); - controller.abort(); - - await expect( - loadVideoPreviewUrl("data:video/mp4;base64,AA==", controller.signal), - ).rejects.toMatchObject({ name: "AbortError" }); - }); -}); - describe("isVideoPreviewRequestCurrent", () => { it("rejects changed threads and replaced previews", () => { expect(isVideoPreviewRequestCurrent("thread-1", "thread-2", 1, 1)).toBe(false); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index a12bacad50d..de349e3dbee 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,4 +1,7 @@ import { + type AssetCreateUrlInput, + type AssetCreateUrlResult, + type ChatFileAttachment, type EnvironmentId, isProviderDriverKind, ProjectId, @@ -12,6 +15,12 @@ import { type ThreadId, type TurnId, } from "@t3tools/contracts"; +import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import { + squashAtomCommandFailure, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import { videoMimeType } from "@t3tools/shared/video"; import { appendCodexArtifactTemplateUsePrompt, codexArtifactTemplateUsePrompt, @@ -299,10 +308,32 @@ export function revokeBlobPreviewUrl(previewUrl: string | undefined): void { URL.revokeObjectURL(previewUrl); } -export async function loadVideoPreviewUrl(url: string, signal?: AbortSignal): Promise { - const response = await fetch(url, signal ? { signal } : {}); - if (!response.ok) throw new Error(`Could not load video (${response.status}).`); - return URL.createObjectURL(await response.blob()); +/** Signs an attachment URL without reading its bytes, so video playback can request byte ranges. */ +export async function resolveFileAttachmentUrl(input: { + attachment: ChatFileAttachment; + environmentId: EnvironmentId; + httpBaseUrl: string; + createAssetUrl: (input: { + environmentId: EnvironmentId; + input: AssetCreateUrlInput; + }) => Promise>; +}): Promise { + const { attachment } = input; + const result = await input.createAssetUrl({ + environmentId: input.environmentId, + input: { + resource: { + _tag: "attachment", + attachmentId: attachment.id, + fileName: attachment.name, + mimeType: videoMimeType(attachment) ?? attachment.mimeType, + }, + }, + }); + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + const url = resolveAssetUrl(input.httpBaseUrl, result.value.relativeUrl); + if (url === null) throw new Error("The environment returned an invalid attachment URL."); + return url; } export function isVideoPreviewRequestCurrent( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 14dc308969c..8c5ba1cbd31 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -366,7 +366,7 @@ import { cloneComposerImageForRetry, deriveLockedProvider, readFileAsDataUrl, - loadVideoPreviewUrl, + resolveFileAttachmentUrl, isVideoPreviewRequestCurrent, reconcileMountedTerminalThreadIds, resolveBackgroundDraftWorkspaceOptions, @@ -425,7 +425,7 @@ import { resolveServerSelfUpdateCapability, serverUpdateGuidance, } from "../versionSkew"; -import { resolveAssetUrl, useAssetUrls } from "../assets/assetUrls"; +import { useAssetUrls } from "../assets/assetUrls"; import { useChatPaneActions, useCurrentChatPaneId } from "~/turbo/chatPanes/ChatPaneActionsContext"; import { ComposerThinkingOrb, TimelineOrb, TimelineOrbSlot } from "~/turbo/orbs/TimelineOrb"; import { chatOrbLabel } from "~/turbo/orbs/chatOrbState"; @@ -1356,6 +1356,7 @@ export function ChatViewContent(props: ChatViewProps) { const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); const createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, + refresh: true, }); const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, { reportFailure: false, @@ -1485,11 +1486,8 @@ export function ChatViewContent(props: ChatViewProps) { const routeThreadKeyRef = useRef(routeThreadKey); routeThreadKeyRef.current = routeThreadKey; const videoPreviewRequestIdRef = useRef(0); - const videoPreviewAbortControllerRef = useRef(null); const cancelVideoPreviewRequest = useCallback(() => { videoPreviewRequestIdRef.current += 1; - videoPreviewAbortControllerRef.current?.abort(); - videoPreviewAbortControllerRef.current = null; }, []); const [openingVideoAttachmentId, setOpeningVideoAttachmentId] = useState(null); const [showScrollToBottom, setShowScrollToBottom] = useState(false); @@ -2598,14 +2596,8 @@ export function ChatViewContent(props: ChatViewProps) { toastManager.add({ type: "error", title: "The environment is not connected." }); return; } - const videoMime = videoMimeType(attachment); - const isVideo = videoMime !== null; + const isVideo = videoMimeType(attachment) !== null; const action = isVideo ? "play" : "download"; - const videoPreviewAbortController = isVideo ? new AbortController() : null; - if (isVideo) { - videoPreviewAbortControllerRef.current?.abort(); - videoPreviewAbortControllerRef.current = videoPreviewAbortController; - } const videoPreviewRequestId = isVideo ? ++videoPreviewRequestIdRef.current : 0; const isCurrentRequest = () => !isVideo || @@ -2615,75 +2607,37 @@ export function ChatViewContent(props: ChatViewProps) { videoPreviewRequestId, videoPreviewRequestIdRef.current, ); - const finishVideoPreviewRequest = () => { - if (videoPreviewRequestIdRef.current === videoPreviewRequestId) { - setOpeningVideoAttachmentId(null); - videoPreviewAbortControllerRef.current = null; - } - }; if (isVideo) setOpeningVideoAttachmentId(attachment.id); - // fileName and mimeType ride in the signed claims so videos render - // inline while other files keep their real download name and type. - const result = await createAttachmentAssetUrl({ - environmentId, - input: { - resource: { - _tag: "attachment", - attachmentId: attachment.id, - fileName: attachment.name, - mimeType: videoMime ?? attachment.mimeType, - }, - }, - }); - if (!isCurrentRequest()) { - finishVideoPreviewRequest(); - return; - } - if (result._tag === "Failure") { - finishVideoPreviewRequest(); - const error = squashAtomCommandFailure(result); - toastManager.add({ - type: "error", - title: "Could not " + action + " " + attachment.name, - description: error instanceof Error ? error.message : "The attachment is unavailable.", + try { + const url = await resolveFileAttachmentUrl({ + attachment, + environmentId, + httpBaseUrl: connection.httpBaseUrl, + createAssetUrl: createAttachmentAssetUrl, }); - return; - } - - const url = resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl); - if (!url) { - finishVideoPreviewRequest(); - toastManager.add({ type: "error", title: "Could not " + action + " " + attachment.name }); - return; - } - if (isVideo) { - try { - const previewUrl = await loadVideoPreviewUrl(url, videoPreviewAbortController?.signal); - if (!isCurrentRequest()) { - revokeBlobPreviewUrl(previewUrl); - return; - } + if (!isCurrentRequest()) return; + if (isVideo) { setExpandedImage({ - images: [{ src: previewUrl, name: attachment.name, type: "video" }], + images: [{ src: url, name: attachment.name, type: "video" }], index: 0, }); - } catch (error) { - if (!isCurrentRequest()) return; - toastManager.add({ - type: "error", - title: "Could not play " + attachment.name, - description: error instanceof Error ? error.message : "The attachment is unavailable.", - }); - } finally { - finishVideoPreviewRequest(); + return; } - return; + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = attachment.name; + anchor.click(); + } catch (error) { + if (!isCurrentRequest()) return; + toastManager.add({ + type: "error", + title: "Could not " + action + " " + attachment.name, + description: error instanceof Error ? error.message : "The attachment is unavailable.", + }); + } finally { + if (isVideo && isCurrentRequest()) setOpeningVideoAttachmentId(null); } - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = attachment.name; - anchor.click(); }, [createAttachmentAssetUrl, environmentId, routeThreadKey], ); @@ -7463,6 +7417,7 @@ export function ChatViewContent(props: ChatViewProps) { } > diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5e83091f767..80d157f64a1 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -180,6 +180,7 @@ import { submitComposerDraft, } from "./composerSubmission"; import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; +import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame"; function ComposerVideoThumbnail({ file }: { file: File }) { const setVideo = useCallback( @@ -197,9 +198,7 @@ function ComposerVideoThumbnail({ file }: { file: File }) { playsInline preload="metadata" aria-hidden="true" - onLoadedMetadata={(event) => { - event.currentTarget.currentTime = Math.min(0.1, event.currentTarget.duration || 0); - }} + onLoadedMetadata={(event) => prepareVideoFirstFrame(event.currentTarget)} className="pointer-events-none absolute inset-0 size-full object-cover" /> ); diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 57f9d779225..04bbeb6ce49 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -1,11 +1,13 @@ +import type { DraftId } from "~/composerDraftStore"; +import { useComposerDraftStore } from "~/composerDraftStore"; import type { ScopedProjectRef } from "@t3tools/contracts"; import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; import { FolderPlusIcon } from "lucide-react"; import { useCallback, useMemo } from "react"; import { openCommandPalette } from "~/commandPaletteBus"; -import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; import { useClientSettings } from "~/hooks/useSettings"; +import { hasExplicitComposerModelSelection } from "~/lib/chatThreadActions"; import { selectProjectGroupingSettings } from "~/logicalProject"; import { buildSidebarProjectPickerEntries, @@ -26,11 +28,13 @@ import { import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; interface DraftHeroHeadlineProps { + readonly draftId: DraftId | null; readonly activeProjectRef: ScopedProjectRef | null; readonly activeProjectTitle: string | null; } export function DraftHeroHeadline({ + draftId, activeProjectRef, activeProjectTitle, }: DraftHeroHeadlineProps) { @@ -40,7 +44,12 @@ export function DraftHeroHeadline({ const primaryEnvironmentId = usePrimaryEnvironmentId(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const projectSortOrder = useClientSettings((settings) => settings.sidebarProjectSortOrder); - const handleNewThread = useNewThreadHandler(); + const setLogicalProjectDraftThreadId = useComposerDraftStore( + (store) => store.setLogicalProjectDraftThreadId, + ); + const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft); + const applyStickyState = useComposerDraftStore((store) => store.applyStickyState); + const setModelSelection = useComposerDraftStore((store) => store.setModelSelection); const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []); const environmentLabelById = useMemo( @@ -126,12 +135,26 @@ export function DraftHeroHeadline({ return; } const project = entry.targetProject; - // Changing the repo of a draft moves the typed content along: - // the user started writing in the wrong project, not a new task. - void handleNewThread(scopeProjectRef(project.environmentId, project.id), { - replace: true, - carryComposerContent: true, - }); + if (!draftId) { + return; + } + // Project selection changes the target of the open draft in + // place. The prompt stays in the same composer session, so the + // sidebar only gets a draft row if the user later navigates away. + const currentDraft = getComposerDraft(draftId); + setLogicalProjectDraftThreadId( + entry.group.projectKey, + scopeProjectRef(project.environmentId, project.id), + draftId, + ); + if (!hasExplicitComposerModelSelection(currentDraft)) { + applyStickyState(draftId); + if (project.defaultModelSelection) { + setModelSelection(draftId, project.defaultModelSelection, { + replaceOptions: true, + }); + } + } }} > {projectPickerEntries.map(({ group }) => { diff --git a/apps/web/src/components/chat/ExpandedImageDialog.test.tsx b/apps/web/src/components/chat/ExpandedImageDialog.test.tsx deleted file mode 100644 index c2a63f34ecd..00000000000 --- a/apps/web/src/components/chat/ExpandedImageDialog.test.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vite-plus/test"; - -import { ExpandedImageDialog } from "./ExpandedImageDialog"; -import type { ExpandedImagePreview } from "./ExpandedImagePreview"; - -describe("ExpandedImageDialog", () => { - it("renders video previews with native controls", () => { - const preview: ExpandedImagePreview = { - images: [ - { - src: "https://environment.test/api/assets/demo.mp4", - name: "demo.mp4", - type: "video", - }, - ], - index: 0, - }; - - const markup = renderToStaticMarkup( - {}} />, - ); - - expect(markup).toContain(" void; } +function ExpandedMediaFailure({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + export const ExpandedImageDialog = memo(function ExpandedImageDialog({ preview, onClose, }: ExpandedImageDialogProps) { const [imageOffset, setImageOffset] = useState(0); const [failedVideoSrc, setFailedVideoSrc] = useState(null); - const [downloadingVideoSrc, setDownloadingVideoSrc] = useState(null); - const [downloadFailedVideoSrc, setDownloadFailedVideoSrc] = useState(null); + const [failedImageSrc, setFailedImageSrc] = useState(null); const index = (preview.index + imageOffset + preview.images.length) % preview.images.length; + const item = preview.images[index]; + const source: MediaActionSource = item?.actionsSource ?? { + kind: item?.type === "video" ? "video" : "image", + name: item?.name ?? "Media", + src: item?.src ?? null, + }; + const openFile = source.onOpenFile; + const actionsSource: MediaActionSource = openFile + ? { + ...source, + onOpenFile: () => { + openFile(); + onClose(); + }, + } + : source; const navigateImage = useCallback((direction: -1 | 1) => { setImageOffset((current) => current + direction); }, []); - const downloadVideo = async (src: string, name: string) => { - setDownloadFailedVideoSrc(null); - setDownloadingVideoSrc(src); - try { - await downloadVideoPreview(src, name); - } catch { - setDownloadFailedVideoSrc(src); - } finally { - setDownloadingVideoSrc((current) => (current === src ? null : current)); - } - }; - useEffect(() => { const onKeyDown = (event: globalThis.KeyboardEvent) => { + if (event.defaultPrevented || isContextMenuOpen()) { + return; + } if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); @@ -58,13 +81,14 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ return () => window.removeEventListener("keydown", onKeyDown); }, [navigateImage, onClose, preview.images.length]); - const item = preview.images[index]; if (!item) return null; const mediaLabel = item.type === "video" ? "video" : "image"; + const openOriginalLink = + item.originalUrl && resolveExternalWebLinkHost(item.originalUrl) !== null ? ( + + ) : null; - const isDownloadingVideo = downloadingVideoSrc === item.src; - const videoDownloadFailed = downloadFailedVideoSrc === item.src; - return ( + return createPortal(
)} -
- - {item.type === "video" && failedVideoSrc === item.src ? ( -
-

- {videoDownloadFailed - ? "Could not download this video." - : "This video format cannot be played here."} -

- -
- ) : item.type === "video" ? ( -
+ +
+ + {item.type === "video" && failedVideoSrc === item.src ? ( + +

This video could not be loaded or played.

+ +
+ ) : item.type === "video" ? ( +
+
{preview.images.length > 1 && ( )} -
+ , + document.body, ); }); diff --git a/apps/web/src/components/chat/ExpandedImagePreview.test.ts b/apps/web/src/components/chat/ExpandedImagePreview.test.ts index 71979a3cb01..f75ba86e4ec 100644 --- a/apps/web/src/components/chat/ExpandedImagePreview.test.ts +++ b/apps/web/src/components/chat/ExpandedImagePreview.test.ts @@ -1,12 +1,46 @@ -import { describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import type { ComposerFileAttachment } from "../../composerDraftStore"; import { attachVideoThumbnail, buildExpandedImagePreview, - downloadVideoPreview, + resolveMarkdownMediaPreview, } from "./ExpandedImagePreview"; +describe("resolveMarkdownMediaPreview", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it.each([ + ["t3code:", "https:"], + ["t3code-dev:", "https:"], + ["http:", "http:"], + ["https:", "https:"], + ])( + "resolves protocol-relative media on %s without changing its source", + async (pageProtocol, mediaProtocol) => { + vi.stubGlobal("window", { location: { protocol: pageProtocol } }); + const source = "//cdn.example.com/recording.mp4?token=a%2FB&v=1#t=2"; + const preview = await resolveMarkdownMediaPreview({ + source, + createAssetUrl: async () => { + throw new Error("Remote media must not request a local asset URL."); + }, + }); + + expect(preview?.images[0]).toMatchObject({ + src: `${mediaProtocol}${source}`, + originalUrl: source, + actionsSource: { + src: `${mediaProtocol}${source}`, + reference: { kind: "url", url: source }, + }, + }); + }, + ); +}); + describe("buildExpandedImagePreview", () => { it("builds a video preview for a local video attachment", () => { const file = new File([new Uint8Array([1, 2, 3])], "demo.mp4", { type: "video/mp4" }); @@ -40,29 +74,4 @@ describe("buildExpandedImagePreview", () => { detach(); await expect(fetch(url)).rejects.toThrow(); }); - - it("downloads a video through a local blob URL", async () => { - vi.useFakeTimers(); - const source = URL.createObjectURL(new Blob([new Uint8Array([1, 2, 3])])); - const click = vi.fn(); - const anchor = { href: "", download: "", click }; - vi.stubGlobal("document", { createElement: () => anchor }); - - try { - await downloadVideoPreview(source, "demo.mp4"); - - expect(anchor.download).toBe("demo.mp4"); - expect(anchor.href).toMatch(/^blob:/); - expect(anchor.href).not.toBe(source); - expect(click).toHaveBeenCalledOnce(); - expect((await fetch(anchor.href)).ok).toBe(true); - - await vi.runAllTimersAsync(); - await expect(fetch(anchor.href)).rejects.toThrow(); - } finally { - URL.revokeObjectURL(source); - vi.useRealTimers(); - vi.unstubAllGlobals(); - } - }); }); diff --git a/apps/web/src/components/chat/ExpandedImagePreview.tsx b/apps/web/src/components/chat/ExpandedImagePreview.tsx index b57a292e0bf..cddfb95052b 100644 --- a/apps/web/src/components/chat/ExpandedImagePreview.tsx +++ b/apps/web/src/components/chat/ExpandedImagePreview.tsx @@ -1,10 +1,34 @@ import type { ComposerFileAttachment } from "../../composerDraftStore"; import { type ChatImageAttachment, isVideoAttachment } from "../../types"; +import type { + AssetCreateUrlResult, + AssetResource, + EnvironmentId, + ScopedThreadRef, +} from "@t3tools/contracts"; +import { + classifyMarkdownImageSource, + markdownImageSourceFragment, +} from "@t3tools/client-runtime/markdown-images"; +import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; +import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import { + squashAtomCommandFailure, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; +import { resolveExternalWebLinkHost } from "./externalLinkContextMenu"; +import type { MediaActionSource } from "../media/MediaActions"; +import { resolveProtocolRelativeMediaUrl } from "../media/mediaContent"; export interface ExpandedImageItem { src: string; name: string; type?: "video"; + autoPlay?: boolean; + /** Authored remote destination to open when embedding fails, never a generated asset URL. */ + originalUrl?: string; + actionsSource?: MediaActionSource; } export interface ExpandedImagePreview { @@ -12,23 +36,98 @@ export interface ExpandedImagePreview { index: number; } +/** Resolves a chat media reference on its owning environment, without downloading its bytes. */ +export async function resolveMarkdownMediaPreview(input: { + source: string; + resolvedFilePath?: string | undefined; + cwd?: string | undefined; + threadRef?: ScopedThreadRef | undefined; + httpBaseUrl?: string | undefined; + onOpenFile?: ((relativePath: string) => void) | undefined; + createAssetUrl: (input: { + environmentId: EnvironmentId; + input: { resource: AssetResource }; + }) => Promise>; +}): Promise { + const source = + input.resolvedFilePath === undefined + ? classifyMarkdownImageSource(input.source, input.cwd) + : { _tag: "WorkspaceFile" as const, path: input.resolvedFilePath }; + if (source._tag === "Blocked") return null; + + const path = + source._tag === "Direct" + ? source.uri.split(/[?#]/, 1)[0]! + : source.path.replace(/:\d+(?::\d+)?$/, ""); + const name = path.split(/[\\/]/).at(-1) ?? ""; + const extensionIndex = name.lastIndexOf("."); + const fileMimeType = + extensionIndex < 0 ? null : mediaMimeTypeFromExtension(name.slice(extensionIndex)); + const kind = + source._tag === "Direct" + ? mediaKindFromPath(source.uri) + : fileMimeType === null + ? null + : fileMimeType.startsWith("video/") + ? "video" + : "image"; + if (kind === null) return null; + + const reference = + source._tag === "Direct" ? mediaUrlReference(source.uri) : mediaFileReference(path, input.cwd); + const relativePath = reference?.kind === "file" ? reference.relativePath : undefined; + let src: string; + let asset: MediaActionSource["asset"]; + if (source._tag === "Direct") { + src = resolveProtocolRelativeMediaUrl(source.uri); + } else { + if (!input.threadRef || !input.httpBaseUrl) { + throw new Error("Reconnect to this environment and open the media again."); + } + asset = { + environmentId: input.threadRef.environmentId, + resource: { _tag: "media-file", threadId: input.threadRef.threadId, path }, + }; + const result = await input.createAssetUrl({ + environmentId: asset.environmentId, + input: { resource: asset.resource }, + }); + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + const assetUrl = resolveAssetUrl(input.httpBaseUrl, result.value.relativeUrl); + if (assetUrl === null) throw new Error("The environment returned an invalid media URL."); + src = assetUrl + markdownImageSourceFragment(input.source); + } + return { + images: [ + { + src, + name: name || kind, + ...(kind === "video" ? { type: "video", autoPlay: false } : {}), + ...(source._tag === "Direct" && resolveExternalWebLinkHost(source.uri) !== null + ? { originalUrl: source.uri } + : {}), + actionsSource: { + kind, + name: name || kind, + src, + ...(reference ? { reference } : {}), + ...(asset ? { asset } : {}), + ...(relativePath && input.onOpenFile + ? { onOpenFile: () => input.onOpenFile?.(relativePath) } + : {}), + }, + }, + ], + index: 0, + }; +} + export function attachVideoThumbnail(video: HTMLVideoElement, file: File): () => void { const url = URL.createObjectURL(file); video.src = url; return () => URL.revokeObjectURL(url); } -export async function downloadVideoPreview(src: string, name: string): Promise { - const response = await fetch(src); - if (!response.ok) throw new Error(`Could not download video (${response.status}).`); - const url = URL.createObjectURL(await response.blob()); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = name; - anchor.click(); - setTimeout(() => URL.revokeObjectURL(url), 30_000); -} - export function buildExpandedImagePreview( images: ReadonlyArray, selectedImageId: string, diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index d9bfdae04d4..d702d6ddda7 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -826,7 +826,7 @@ describe("deriveMessagesTimelineRows", () => { "assistant-final-entry", "user-followup-entry", "working-indicator-row", - "thinking-indicator-row", + "live-activity-row", ]); const finalRow = rows.find((row) => row.id === "assistant-final-entry"); expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true); @@ -879,11 +879,11 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.map((row) => row.id)).toEqual([ "working-indicator-row", "assistant-thought-entry", - "work-live:work-entry-1", + "live-activity-row", ]); }); - it("keeps adjacent active tool calls in one replacing row", () => { + it("keeps an actually running tool in the shared activity row", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ { @@ -948,6 +948,7 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.some((row) => row.kind === "thinking")).toBe(false); expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ entry: { id: "running-command" }, + active: true, groupedEntries: [ { id: "running-command" }, { id: "completed-edit" }, @@ -1196,40 +1197,58 @@ describe("deriveMessagesTimelineRows", () => { ]); }); - it("shows thinking after the latest tool call completes while the turn is running", () => { - const rows = deriveMessagesTimelineRows({ - timelineEntries: [ - { - id: "latest-command-entry", - kind: "work", - createdAt: "2026-01-01T00:00:05Z", - entry: { - id: "latest-command", - createdAt: "2026-01-01T00:00:05Z", - turnId: "turn-1" as never, - label: "Ran rg", - command: "rg toolCall", - requestKind: "command", - tone: "tool" as const, - toolLifecycleStatus: "completed" as const, - }, + it("reuses one activity row for initial thinking and the latest tool", () => { + const deriveRows = (toolLifecycleStatus: "inProgress" | "completed" | "declined" | null) => + deriveMessagesTimelineRows({ + timelineEntries: + toolLifecycleStatus === null + ? [] + : [ + { + id: "latest-command-entry", + kind: "work", + createdAt: "2026-01-01T00:00:05Z", + entry: { + id: "latest-command", + createdAt: "2026-01-01T00:00:05Z", + turnId: "turn-1" as never, + label: toolLifecycleStatus === "inProgress" ? "Running rg" : "Ran rg", + command: "rg toolCall", + requestKind: "command", + tone: "tool" as const, + toolLifecycleStatus, + }, + }, + ], + latestTurn: { + turnId: "turn-1" as never, + state: "running", + startedAt: "2026-01-01T00:00:00Z", + completedAt: null, }, - ], - latestTurn: { - turnId: "turn-1" as never, - state: "running", - startedAt: "2026-01-01T00:00:00Z", - completedAt: null, - }, - isWorking: true, - activeTurnStartedAt: "2026-01-01T00:00:00Z", - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), - }); + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); - expect(rows.map((row) => row.kind)).toEqual(["working", "work-live", "thinking"]); - expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ active: false }); - expect(rows.at(-1)).toMatchObject({ kind: "thinking" }); + const initialRows = deriveRows(null); + const runningRows = deriveRows("inProgress"); + const completedRows = deriveRows("completed"); + const declinedRows = deriveRows("declined"); + const initialActivityRow = initialRows.find((row) => row.id === "live-activity-row"); + const runningActivityRow = runningRows.find((row) => row.id === "live-activity-row"); + const completedActivityRow = completedRows.find((row) => row.id === "live-activity-row"); + + expect(initialActivityRow).toMatchObject({ kind: "thinking" }); + expect(runningActivityRow).toMatchObject({ kind: "work-live", active: true }); + expect(completedActivityRow).toMatchObject({ kind: "work-live", active: true }); + expect(declinedRows.find((row) => row.kind === "work-live")).toMatchObject({ active: false }); + expect(declinedRows.at(-1)).toMatchObject({ kind: "thinking", id: "live-activity-row" }); + expect(initialRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); + expect(runningRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); + expect(completedRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); + expect(declinedRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); }); it("does not fold the session's running turn when latestTurn regresses", () => { @@ -1290,7 +1309,7 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([ "turn-1", ]); - expect(rows.map((row) => row.id)).toContain("work-live:running-work-entry"); + expect(rows.map((row) => row.id)).toContain("live-activity-row"); }); it("only shows assistant metadata on the terminal assistant message", () => { @@ -1587,8 +1606,8 @@ describe("computeStableMessagesTimelineRows", () => { initial, ); - const initialThinking = initial.byId.get("thinking-indicator-row"); - const updatedThinking = updated.byId.get("thinking-indicator-row"); + const initialThinking = initial.byId.get("live-activity-row"); + const updatedThinking = updated.byId.get("live-activity-row"); expect(initialThinking).toMatchObject({ kind: "thinking" }); expect(updatedThinking).toBe(initialThinking); expect(updated.result.at(-1)).toBe(updatedThinking); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index c787446f738..9f879480185 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -15,6 +15,7 @@ export { import { formatDuration, workEntryDisplayIndicatesToolFailure, + workEntryIndicatesToolSuccess, workEntryIndicatesToolNeutralStatus, workLogEntryIsToolLike, type TimelineEntry, @@ -188,6 +189,8 @@ export type TimelineLatestTurn = Pick< "turnId" | "state" | "startedAt" | "completedAt" >; +const LIVE_ACTIVITY_ROW_ID = "live-activity-row"; + export type MessagesTimelineRow = | { kind: "work"; @@ -598,21 +601,26 @@ export function deriveMessagesTimelineRows(input: { const latestRunningToolEntry = visibleActiveToolEntries.findLast((entry) => workEntryIsActiveTurnActivity(entry.entry), ); - const displayedToolEntry = latestRunningToolEntry ?? latestVisibleToolEntry; + const latestToolKeepsActivityLive = + latestRunningToolEntry !== undefined || + (latestVisibleToolEntry !== undefined && + workEntryIndicatesToolSuccess(latestVisibleToolEntry.entry)); const activeWorkPlacementEntryId = latestVisibleToolEntry?.id; const activeWorkRow = - activeWorkAnchor && displayedToolEntry + activeWorkAnchor && latestVisibleToolEntry ? (() => { const groupId = workGroupId(activeWorkAnchor.id, activeWorkAnchor.entry); return { kind: "work-live" as const, - id: `work-live:${workGroupIdentity(activeWorkAnchor.id, activeWorkAnchor.entry)}`, + id: latestToolKeepsActivityLive + ? LIVE_ACTIVITY_ROW_ID + : `work-live:${workGroupIdentity(activeWorkAnchor.id, activeWorkAnchor.entry)}`, createdAt: activeWorkAnchor.createdAt, - entry: displayedToolEntry.entry, + entry: (latestRunningToolEntry ?? latestVisibleToolEntry).entry, groupedEntries: visibleActiveToolEntries.map((entry) => entry.entry), groupId, expanded: input.expandedWorkGroupIds?.has(groupId) ?? false, - active: latestRunningToolEntry !== undefined, + active: latestToolKeepsActivityLive, }; })() : null; @@ -626,11 +634,11 @@ export function deriveMessagesTimelineRows(input: { createdAt: input.activeTurnStartedAt, }); }; - let hasLiveWorkRow = false; + let hasActivityRow = false; const appendActiveWorkRows = () => { if (activeWorkRow === null) return; nextRows.push(activeWorkRow); - hasLiveWorkRow ||= activeWorkRow.active; + hasActivityRow ||= activeWorkRow.active; if (!activeWorkRow.expanded) return; for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) { nextRows.push({ @@ -730,7 +738,7 @@ export function deriveMessagesTimelineRows(input: { expanded, active: true, }); - hasLiveWorkRow = true; + hasActivityRow = true; if (expanded) { for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) { nextRows.push({ @@ -832,10 +840,10 @@ export function deriveMessagesTimelineRows(input: { if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { appendWorkingRow(); } - if (input.isWorking && !hasLiveWorkRow) { + if (input.isWorking && !hasActivityRow) { nextRows.push({ kind: "thinking", - id: "thinking-indicator-row", + id: LIVE_ACTIVITY_ROW_ID, createdAt: input.activeTurnStartedAt, }); } diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 024dfe69d27..5c6dbbf5b2e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1308,7 +1308,30 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("tool call failed"); }); - it("keeps declined command copy visible while thinking continues", () => { + it("renders initial thinking as the shared live activity row", () => { + const turnId = TurnId.make("turn-live"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Thinking"); + expect(markup).toContain("lucide-brain"); + expect(markup).toContain('data-timeline-row-id="live-activity-row"'); + }); + + it("keeps the completed command in the shared activity row", () => { const turnId = TurnId.make("turn-live"); const markup = renderToStaticMarkup( { runningTurnId={turnId} timelineEntries={[ { - id: "entry-declined", + id: "entry-completed", kind: "work", createdAt: MESSAGE_CREATED_AT, entry: { - id: "work-declined", + id: "work-completed", createdAt: MESSAGE_CREATED_AT, turnId, - toolCallId: "call-declined", + toolCallId: "call-completed", label: "Run lint", tone: "tool", itemType: "command_execution", command: "pnpm lint", - toolLifecycleStatus: "declined", + toolLifecycleStatus: "completed", }, }, ]} />, ); - expect(markup).toContain("Declined pnpm"); - expect(markup).toContain("Thinking"); - expect(markup).toContain("tool call failed"); + expect(markup).toContain("Running pnpm"); + expect(markup).toContain("lucide-terminal"); + expect(markup).toContain("live-activity-focus"); + expect(markup).not.toContain("Ran pnpm"); + expect(markup).not.toContain("Thinking"); + expect(markup).not.toContain('data-timeline-row-kind="thinking"'); }); it("renders review comment contexts as structured cards instead of raw tags", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 4d243ae59a3..035162b278b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -62,6 +62,7 @@ import { import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown"; import { BotIcon, + BrainIcon, CheckIcon, ChevronDownIcon, ChevronRightIcon, @@ -987,14 +988,15 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time : "pb-0" : isExpandedToolGroupHeader ? "pb-0" - : row.kind === "turn-fold" || row.kind === "working" || row.kind === "thinking" + : row.kind === "turn-fold" || row.kind === "working" ? "pb-1.5" : (row.kind === "message" && row.message.role === "assistant" && !row.showAssistantMeta) || row.kind === "work" || row.kind === "work-live" || - row.kind === "work-toggle" + row.kind === "work-toggle" || + row.kind === "thinking" ? "pb-2" : "pb-4", row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null, @@ -1366,7 +1368,7 @@ function ThinkingTimelineRow() { // Reserve the activity row during setup so the handoff keeps the same height. return (
- {isPreparingWorktree ? null : } + {isPreparingWorktree ? null : }
); } @@ -2125,6 +2127,7 @@ function formatWorkingTimerNow(startIso: string): string { type WorkEntryIconName = | "bot" + | "brain" | "check" | "circle-alert" | "eye" @@ -2142,6 +2145,8 @@ function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; classN switch (name) { case "bot": return ; + case "brain": + return ; case "check": return ; case "circle-alert": @@ -2181,7 +2186,7 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): { } if (tone === "thinking") { return { - iconName: "bot", + iconName: "brain", className: "text-foreground", }; } @@ -2561,6 +2566,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { resource={viewedImage.resource} alt={viewedImage.alt} srcFragment={viewedImage.srcFragment} + workspaceRoot={workspaceRoot} style={{ maxHeight: "16rem" }} onImageExpand={onImageExpand} /> diff --git a/apps/web/src/components/chat/externalLinkContextMenu.test.ts b/apps/web/src/components/chat/externalLinkContextMenu.test.ts index 9eb924280f8..5761d4f9514 100644 --- a/apps/web/src/components/chat/externalLinkContextMenu.test.ts +++ b/apps/web/src/components/chat/externalLinkContextMenu.test.ts @@ -181,6 +181,8 @@ describe("external chat link context menu", () => { it.each([ ["https://example.com", "example.com"], ["http://localhost:3000/path", "localhost"], + ["//cdn.example.com/clip.mp4?signature=abc#t=2", "cdn.example.com"], + ["//", null], ["#details", null], ["mailto:hello@example.com", null], ["file:///tmp/example.txt", null], diff --git a/apps/web/src/components/chat/externalLinkContextMenu.ts b/apps/web/src/components/chat/externalLinkContextMenu.ts index b31f27668ad..d0f37f97d80 100644 --- a/apps/web/src/components/chat/externalLinkContextMenu.ts +++ b/apps/web/src/components/chat/externalLinkContextMenu.ts @@ -76,7 +76,7 @@ interface ShowExternalLinkContextMenuOptions { export function resolveExternalWebLinkHost(href: string | undefined): string | null { if (!href) return null; try { - const url = new URL(href); + const url = new URL(href.startsWith("//") ? `https:${href}` : href); if (url.protocol !== "http:" && url.protocol !== "https:") return null; return url.hostname || null; } catch { diff --git a/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx b/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx new file mode 100644 index 00000000000..e97a46a2a25 --- /dev/null +++ b/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx @@ -0,0 +1,105 @@ +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import type { DesktopAppActivationRequest } from "@t3tools/contracts"; +import { useEffect, useEffectEvent, useRef } from "react"; + +import { handleDesktopAppActivationRequest } from "../../desktopAppActivation"; +import { useNewThreadHandler } from "../../hooks/useHandleNewThread"; +import { findProjectByPath, inferProjectTitleFromPath } from "../../lib/projectPaths"; +import { newProjectId } from "../../lib/utils"; +import { resolveDefaultProviderModelSelection } from "../../providerInstances"; +import { readProjects, waitForProject } from "../../state/entities"; +import { usePrimaryEnvironment } from "../../state/environments"; +import { projectEnvironment } from "../../state/projects"; +import { useEnvironmentQuery } from "../../state/query"; +import { environmentShell } from "../../state/shell"; +import { useAtomCommand } from "../../state/use-atom-command"; + +export function DesktopAppActivationCoordinator() { + const primaryEnvironment = usePrimaryEnvironment(); + const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); + const openThread = useNewThreadHandler(); + const queueRef = useRef(Promise.resolve()); + const activation = window.desktopBridge?.appActivation; + const shell = useEnvironmentQuery( + primaryEnvironment === null + ? null + : environmentShell.stateAtom(primaryEnvironment.environmentId), + ); + const ready = + activation !== undefined && + primaryEnvironment?.connection.phase === "connected" && + primaryEnvironment.serverConfig !== null && + shell.data?.snapshot._tag === "Some"; + + const processRequest = useEffectEvent(async (request: DesktopAppActivationRequest) => + handleDesktopAppActivationRequest(request, { + getTarget: () => { + if ( + primaryEnvironment?.connection.phase !== "connected" || + primaryEnvironment.serverConfig === null + ) { + return null; + } + return { + environmentId: primaryEnvironment.environmentId, + platform: primaryEnvironment.serverConfig.environment.platform.os, + }; + }, + findProject: (environmentId, workspaceRoot) => + findProjectByPath( + readProjects().filter((project) => project.environmentId === environmentId), + workspaceRoot, + ) ?? null, + createProject: async (environmentId, workspaceRoot) => { + const projectId = newProjectId(); + const providers = + primaryEnvironment?.environmentId === environmentId + ? (primaryEnvironment.serverConfig?.providers ?? []) + : []; + const result = await createProject({ + environmentId, + input: { + projectId, + title: inferProjectTitleFromPath(workspaceRoot), + workspaceRoot, + createWorkspaceRootIfMissing: false, + defaultModelSelection: resolveDefaultProviderModelSelection(providers, null), + }, + }); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + throw error instanceof Error ? error : new Error("T3 Code could not add the project."); + } + return projectId; + }, + waitForProject: async (projectRef) => { + await waitForProject(projectRef); + }, + openThread: (projectRef) => openThread(projectRef), + }), + ); + + useEffect(() => { + if (!ready || activation === undefined) return; + + let subscribed = true; + const unsubscribe = activation.onRequest((request) => { + queueRef.current = queueRef.current.then(async () => { + const response = await processRequest(request); + await activation.complete(response); + }); + queueRef.current = queueRef.current.catch(() => undefined); + }); + // Skip readiness if React runs cleanup before this subscription can receive requests. + queueMicrotask(() => { + if (subscribed) void activation.setReady(true).catch(() => undefined); + }); + return () => { + subscribed = false; + void activation.setReady(false).catch(() => undefined); + unsubscribe(); + }; + }, [activation, ready]); + + return null; +} diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index ee05ef52cf7..99368145f8d 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -4,7 +4,10 @@ import type { ResolvedKeybindingsConfig, ScopedThreadRef, } from "@t3tools/contracts"; -import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; +import { + isWorkspaceImagePreviewPath, + isWorkspaceVideoPreviewPath, +} from "@t3tools/shared/filePreview"; import { VirtualizedFile, type SelectedLineRange } from "@pierre/diffs"; import { Editor } from "@pierre/diffs/editor"; import { EditProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react"; @@ -12,13 +15,16 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; import { ChevronRight, Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react"; import * as Schema from "effect/Schema"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview"; -import { useAssetUrlState } from "~/assets/assetUrls"; +import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls"; import { OpenInPicker } from "~/components/chat/OpenInPicker"; +import { MediaVideoPlayer } from "~/components/media/MediaVideoPlayer"; +import { MediaActions, type MediaActionSource } from "~/components/media/MediaActions"; import { useRemoteOpenState } from "~/remoteOpen"; import { useClientSettings } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; @@ -139,37 +145,53 @@ function WorkspaceImagePreview(props: { readonly environmentId: EnvironmentId; readonly threadRef: ScopedThreadRef; readonly absolutePath: string; + readonly workspaceRoot: string; readonly alt: string; readonly workspaceMutationId: string | null; }) { - const assetUrl = useAssetUrlState(props.environmentId, { - _tag: "workspace-file", - threadId: props.threadRef.threadId, - path: props.absolutePath, - }); + const resource = useMemo( + () => ({ + _tag: "workspace-file" as const, + threadId: props.threadRef.threadId, + path: props.absolutePath, + }), + [props.threadRef.threadId, props.absolutePath], + ); + const assetUrl = useAssetUrlState(props.environmentId, resource); const [failedUrl, setFailedUrl] = useState(null); const revisionSuffix = props.workspaceMutationId === null ? "" : `${assetUrl._tag === "Success" && assetUrl.url.includes("?") ? "&" : "?"}workspace-revision=${encodeURIComponent(props.workspaceMutationId)}`; const imageUrl = assetUrl._tag === "Success" ? `${assetUrl.url}${revisionSuffix}` : null; + const actionsSource: MediaActionSource = { + kind: "image", + name: props.alt, + src: imageUrl, + reference: mediaFileReference(props.absolutePath, props.workspaceRoot), + asset: { environmentId: props.environmentId, resource }, + }; if (assetUrl._tag === "Failure" || (imageUrl !== null && failedUrl === imageUrl)) { return ( -
- Unable to load workspace image. -
+ +
+ Unable to load workspace image. +
+
); } return assetUrl._tag === "Success" && imageUrl !== null ? (
- {props.alt} setFailedUrl(imageUrl)} - /> + + {props.alt} setFailedUrl(imageUrl)} + /> +
) : (
@@ -178,6 +200,60 @@ function WorkspaceImagePreview(props: { ); } +function WorkspaceVideoPreview(props: { + readonly environmentId: EnvironmentId; + readonly threadRef: ScopedThreadRef; + readonly absolutePath: string; + readonly workspaceRoot: string; + readonly name: string; + readonly workspaceMutationId: string | null; +}) { + const resource = useMemo( + () => ({ + _tag: "media-file" as const, + threadId: props.threadRef.threadId, + path: props.absolutePath, + }), + [props.threadRef.threadId, props.absolutePath], + ); + const assetUrl = useAssetUrlState(props.environmentId, resource); + const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, resource); + useWorkspaceMutationRefresh({ + mutationId: props.workspaceMutationId, + resourceKey: JSON.stringify([props.environmentId, resource]), + refresh: () => { + // Failed refreshes flow through assetUrl and can be retried from the player. + void refreshAssetUrl().catch(() => undefined); + }, + }); + const revisionSuffix = + props.workspaceMutationId === null + ? "" + : `${assetUrl._tag === "Success" && assetUrl.url.includes("?") ? "&" : "?"}workspace-revision=${encodeURIComponent(props.workspaceMutationId)}`; + const latestUrl = assetUrl._tag === "Success" ? `${assetUrl.url}${revisionSuffix}` : null; + + return ( +
+ +
+ ); +} + function clampFileLine(contents: string, requestedLine: number): number { let lineCount = 1; for (let index = 0; index < contents.length; index += 1) { @@ -798,8 +874,10 @@ export default function FilePreviewPanel({ const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false, }); - const isImage = relativePath !== null && isWorkspaceImagePreviewPath(relativePath); - const file = useProjectFileQuery(environmentId, cwd, relativePath, !isImage); + const isVideo = relativePath !== null && isWorkspaceVideoPreviewPath(relativePath); + const isImage = relativePath !== null && !isVideo && isWorkspaceImagePreviewPath(relativePath); + const isMedia = isImage || isVideo; + const file = useProjectFileQuery(environmentId, cwd, relativePath, !isMedia); const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen); // Reading markdown rendered is a preference, not a property of one file. Keeping // it on the panel meant a thread switch dropped it and forced source back. @@ -819,7 +897,10 @@ export default function FilePreviewPanel({ (revealLine === null || (handledReveal?.path === relativePath && handledReveal.requestId === revealRequestId)); const canOpenInBrowser = - relativePath !== null && isPreviewSupportedInRuntime() && isBrowserPreviewFile(relativePath); + relativePath !== null && + !isVideo && + isPreviewSupportedInRuntime() && + isBrowserPreviewFile(relativePath); const absolutePath = relativePath ? resolvePathLinkTarget(relativePath, cwd) : null; const breadcrumbs = useMemo( () => (relativePath ? fileBreadcrumbs(projectName, relativePath) : []), @@ -840,7 +921,7 @@ export default function FilePreviewPanel({ [threadRef], ); useWorkspaceMutationRefresh({ - enabled: relativePath !== null && !isImage && !selectedFilePending, + enabled: relativePath !== null && !isMedia && !selectedFilePending, mutationId: workspaceMutationId, refresh: file.refresh, resourceKey: `file:${environmentId}:${cwd}:${relativePath ?? ""}`, @@ -1017,7 +1098,7 @@ export default function FilePreviewPanel({
) : null} - {relativePath && file.data?.truncated ? ( + {relativePath && !isMedia && file.data?.truncated ? (
Preview limited to the first 1 MB of a {file.data.byteLength.toLocaleString()} byte file.
@@ -1029,12 +1110,23 @@ export default function FilePreviewPanel({ relativePath ? "flex" : "hidden", )} > - {relativePath && isImage && absolutePath ? ( + {relativePath && isVideo && absolutePath ? ( + + ) : relativePath && isImage && absolutePath ? ( @@ -1122,7 +1214,7 @@ export default function FilePreviewPanel({ onFileDeleted={reconcileDeletedFile} isFileMutationPending={isFileMutationPending} workspaceMutationId={workspaceMutationId} - {...(relativePath && !isImage ? { onRefreshSelectedFile: file.refresh } : {})} + {...(relativePath && !isMedia ? { onRefreshSelectedFile: file.refresh } : {})} /> ) : null} diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index 08203ff6b87..d02ec99605b 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -4,6 +4,10 @@ import type { ProjectListEntriesResult, ProjectReadFileResult, } from "@t3tools/contracts"; +import { + isWorkspaceImagePreviewPath, + isWorkspaceVideoPreviewPath, +} from "@t3tools/shared/filePreview"; import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -178,9 +182,13 @@ export function useProjectFileQuery( relativePath: string | null, enabled = true, ): ProjectQueryState { - const atom = enabled - ? getProjectFileQueryAtom(environmentId, cwd, relativePath) - : EMPTY_PROJECT_FILE_QUERY_ATOM; + const isMedia = + relativePath !== null && + (isWorkspaceImagePreviewPath(relativePath) || isWorkspaceVideoPreviewPath(relativePath)); + const atom = + enabled && !isMedia + ? getProjectFileQueryAtom(environmentId, cwd, relativePath) + : EMPTY_PROJECT_FILE_QUERY_ATOM; const result = useAtomValue(atom); const refreshAtom = useAtomRefresh(atom); const refresh = useCallback(() => refreshAtom(), [refreshAtom]); diff --git a/apps/web/src/components/media/MediaActions.tsx b/apps/web/src/components/media/MediaActions.tsx new file mode 100644 index 00000000000..a67cadbca5a --- /dev/null +++ b/apps/web/src/components/media/MediaActions.tsx @@ -0,0 +1,189 @@ +import { + mediaReferenceFileName, + type MediaReference, +} from "@t3tools/client-runtime/media-reference"; +import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import type { AssetResource, ContextMenuItem, EnvironmentId } from "@t3tools/contracts"; +import { useCallback, useRef, useState, type ReactElement } from "react"; + +import { writeTextToClipboard } from "../../hooks/useCopyToClipboard"; +import { readLocalApi } from "../../localApi"; +import { assetEnvironment } from "../../state/assets"; +import { readPreparedConnection } from "../../state/session"; +import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { downloadMedia, readMediaPng } from "./mediaContent"; + +export interface MediaActionSource { + readonly kind: "image" | "video"; + readonly name: string; + readonly src: string | null; + readonly reference?: MediaReference; + readonly asset?: { readonly environmentId: EnvironmentId; readonly resource: AssetResource }; + readonly onOpenFile?: () => void; +} + +function mediaFileName(source: MediaActionSource): string { + return ( + (source.reference && mediaReferenceFileName(source.reference)) || source.name || source.kind + ); +} + +/** Explicit byte operations get fresh capabilities without replacing a player's active source. */ +export function useMediaActions(source: MediaActionSource) { + const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { + reportFailure: false, + refresh: true, + }); + const actionUrl = useCallback(async () => { + if (!source.asset) { + if (!source.src) throw new Error("This media is unavailable. Try reopening the preview."); + return source.src; + } + const { environmentId, resource } = source.asset; + const connection = readPreparedConnection(environmentId); + if (!connection) throw new Error("Reconnect to this environment and try again."); + const result = await createAssetUrl({ environmentId, input: { resource } }); + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + const url = resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl); + if (!url) throw new Error("The environment returned an invalid media URL."); + return url; + }, [source, createAssetUrl]); + const save = useCallback(async () => { + await downloadMedia(await actionUrl(), mediaFileName(source)); + }, [actionUrl, source]); + const copyImage = useCallback(async () => { + if (!navigator.clipboard?.write || typeof ClipboardItem === "undefined") { + throw new Error( + "Image copying is unavailable. Use a secure browser connection or save the image.", + ); + } + // Start the clipboard write in the user gesture; fetching/decoding may finish later. + await navigator.clipboard.write([ + new ClipboardItem({ "image/png": actionUrl().then(readMediaPng) }), + ]); + }, [actionUrl]); + return { save, copyImage }; +} + +type MediaAction = "copy-full" | "copy-relative" | "copy-url" | "save" | "copy-image" | "open-file"; + +/** Adds source-aware actions and a tooltip to the existing media element without a layout wrapper. */ +export function MediaActions({ + source, + children, +}: { + source: MediaActionSource; + children: ReactElement; +}) { + const { save, copyImage } = useMediaActions(source); + const [tooltipOpen, setTooltipOpen] = useState(false); + const menuOpen = useRef(false); + const reference = source.reference; + const hasActions = + source.kind === "image" || reference !== undefined || source.onOpenFile !== undefined; + const tooltip = reference?.kind === "file" ? reference.path : (reference?.url ?? source.name); + + const showMenu = async (position: { x: number; y: number }) => { + const api = readLocalApi(); + if (!api || menuOpen.current) return; + menuOpen.current = true; + setTooltipOpen(false); + let failureTitle = "Could not open media menu"; + let progressToast: ReturnType | undefined; + try { + const items: ContextMenuItem[] = []; + if (reference?.kind === "file") { + items.push({ id: "copy-full", label: "Copy full path" }); + if (reference.relativePath) + items.push({ id: "copy-relative", label: "Copy relative path" }); + } else if (reference?.kind === "url") { + items.push({ id: "copy-url", label: "Copy URL" }); + } + if (source.kind === "image") { + const unavailable = source.src === null && source.asset === undefined; + items.push({ id: "save", label: "Save image", disabled: unavailable }); + items.push({ id: "copy-image", label: "Copy image", disabled: unavailable }); + } + if (source.onOpenFile) items.push({ id: "open-file", label: "Open in file viewer" }); + + const action = await api.contextMenu.show(items, position); + if (!action) return; + failureTitle = `Could not ${items.find((item) => item.id === action)?.label.toLowerCase() ?? "complete media action"}`; + const text = + action === "copy-full" && reference?.kind === "file" + ? reference.path + : action === "copy-relative" && reference?.kind === "file" + ? reference.relativePath + : action === "copy-url" && reference?.kind === "url" + ? reference.url + : undefined; + if (text !== undefined) { + await writeTextToClipboard(text, reference?.kind === "file" ? "file path" : "URL"); + toastManager.add({ + type: "success", + title: action === "copy-url" ? "URL copied" : "Path copied", + }); + } else if (action === "open-file") { + source.onOpenFile?.(); + } else if (action === "save" || action === "copy-image") { + progressToast = toastManager.add({ + type: "loading", + title: action === "save" ? "Preparing image download…" : "Copying image…", + }); + await (action === "save" ? save() : copyImage()); + toastManager.update(progressToast, { + type: "success", + title: action === "save" ? "Download started" : "Image copied", + }); + } + } catch (error) { + const toast = stackedThreadToast({ + type: "error", + title: failureTitle, + description: error instanceof Error ? error.message : "The media action failed.", + }); + if (progressToast) toastManager.update(progressToast, toast); + else toastManager.add(toast); + } finally { + menuOpen.current = false; + } + }; + + return ( + + { + if (!hasActions || event.defaultPrevented) return; + event.preventDefault(); + event.stopPropagation(); + const bounds = event.currentTarget.getBoundingClientRect(); + void showMenu( + event.clientX === 0 && event.clientY === 0 + ? { x: bounds.left, y: bounds.bottom } + : { x: event.clientX, y: event.clientY }, + ); + }} + onKeyDown={(event) => { + if ( + !hasActions || + event.defaultPrevented || + !(event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) + ) + return; + event.preventDefault(); + event.stopPropagation(); + const bounds = event.currentTarget.getBoundingClientRect(); + void showMenu({ x: bounds.left, y: bounds.bottom }); + }} + /> + + {tooltip} + + + ); +} diff --git a/apps/web/src/components/media/MediaVideoPlayer.tsx b/apps/web/src/components/media/MediaVideoPlayer.tsx new file mode 100644 index 00000000000..8f2d75680c1 --- /dev/null +++ b/apps/web/src/components/media/MediaVideoPlayer.tsx @@ -0,0 +1,197 @@ +import { Maximize2Icon, RotateCwIcon, TriangleAlertIcon } from "lucide-react"; +import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; + +import { cn } from "../../lib/utils"; +import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame"; +import { Button } from "../ui/button"; +import { OpenMediaLink } from "./OpenMediaLink"; +import { MediaActions, type MediaActionSource } from "./MediaActions"; + +interface MediaVideoPlayerProps { + readonly src: string | null; + readonly label: string; + readonly sourceFailed?: boolean | undefined; + readonly originalUrl?: string | undefined; + readonly revision?: string | null | undefined; + readonly preload?: "visible" | "metadata" | undefined; + readonly className?: string | undefined; + readonly videoClassName?: string | undefined; + readonly style?: CSSProperties | undefined; + readonly copyMarkdown?: string | undefined; + readonly onExpand?: ((src: string) => void) | undefined; + readonly onRetry?: (() => Promise) | undefined; + readonly actionsSource?: MediaActionSource | undefined; +} + +/** Keeps native range streaming and playback state consistent across inline and file previews. */ +export function MediaVideoPlayer({ + src: latestSrc, + label, + sourceFailed = false, + originalUrl, + revision = null, + preload = "visible", + className, + videoClassName, + style, + copyMarkdown, + onExpand, + onRetry, + actionsSource, +}: MediaVideoPlayerProps) { + const videoRef = useRef(null); + const [playbackSource, setPlaybackSource] = useState<{ + src: string; + revision: string | null; + } | null>(null); + const [failedSrc, setFailedSrc] = useState(null); + const [retrying, setRetrying] = useState(false); + const [loadAttempt, setLoadAttempt] = useState(0); + const [preloadedSrc, setPreloadedSrc] = useState(null); + const src = playbackSource?.src ?? latestSrc; + const sourceRevision = playbackSource === null ? revision : playbackSource.revision; + const failed = src !== null ? failedSrc === src : sourceFailed; + + // Re-signing must not reset the playhead. Changed files refresh once playback pauses. + const refreshPausedRevision = useCallback(() => { + const video = videoRef.current; + if (video === null || video.paused || video.ended) { + setPlaybackSource((current) => + current !== null && current.revision !== revision ? null : current, + ); + } + }, [revision]); + useEffect(refreshPausedRevision, [refreshPausedRevision]); + + useEffect(() => { + const video = videoRef.current; + if (!video || preload === "metadata" || preloadedSrc === src) return; + if (typeof IntersectionObserver === "undefined") { + setPreloadedSrc(src); + return; + } + let active = true; + const observer = new IntersectionObserver( + (entries) => { + if (!active || !entries.some((entry) => entry.isIntersecting)) return; + setPreloadedSrc(src); + observer.disconnect(); + }, + { rootMargin: "200px" }, + ); + observer.observe(video); + return () => { + active = false; + observer.disconnect(); + }; + }, [src, preload, preloadedSrc, failed, loadAttempt]); + + useEffect(() => { + const video = videoRef.current; + if (!video) return; + const pauseWhenHidden = () => { + if (document.hidden) video.pause(); + }; + document.addEventListener("visibilitychange", pauseWhenHidden); + return () => { + document.removeEventListener("visibilitychange", pauseWhenHidden); + video.pause(); + }; + }, [src, failed, loadAttempt]); + + const retry = async () => { + if (retrying) return; + setRetrying(true); + try { + await onRetry?.(); + setPlaybackSource(null); + setFailedSrc(null); + setLoadAttempt((current) => current + 1); + } catch { + setFailedSrc(src); + } finally { + setRetrying(false); + } + }; + + const expandButton = + onExpand && src !== null ? ( + + ) : null; + + const player = ( + + {failed ? ( + + + + Video unavailable{label ? ` · ${label}` : ""} + + + {latestSrc !== null || onRetry ? ( + + ) : null} + + {expandButton} + + + ) : src !== null ? ( +