diff --git a/apps/desktop/docs/LANGUAGE_SERVICES.md b/apps/desktop/docs/LANGUAGE_SERVICES.md new file mode 100644 index 00000000000..19e5742edf5 --- /dev/null +++ b/apps/desktop/docs/LANGUAGE_SERVICES.md @@ -0,0 +1,64 @@ +# Desktop Language Services + +This document tracks the IDE-oriented diagnostics stack used by the desktop app. + +## Goals + +- Keep editor and sidebar UI stable while adding language-specific diagnostics. +- Match VS Code behavior as closely as practical for each language. +- Make it easy to add new providers behind the same manager/store/router flow. + +## Current Providers + +### TypeScript / JavaScript / TSX / JSX + +- Backend: `tsserver` +- Reason: VS Code uses `tsserver` for TypeScript and JavaScript language features, so this is the closest path to parity. +- Source: + - https://github.com/microsoft/TypeScript/wiki/Standalone-Server-%28tsserver%29 + +### JSON / JSONC + +- Backend: `vscode-json-languageservice` +- Reason: This is the JSON language service used in the VS Code ecosystem and supports schema-backed validation. +- Source: + - https://github.com/microsoft/vscode-json-languageservice + +### TOML + +- Backend: `@taplo/lib` +- Reason: Taplo is the de facto TOML toolkit with a maintained JavaScript/WASM entrypoint suitable for desktop embedding. +- Source: + - https://taplo.tamasfe.dev/lib/javascript/lib.html + +### Dart / Flutter + +- Backend: Dart language server via `dart language-server` +- Reason: This matches the official Dart analysis server/LSP flow and works for both Dart and Flutter projects. +- Sources: + - https://dart.dev/tools/analysis-server + - https://raw.githubusercontent.com/dart-lang/sdk/main/pkg/analysis_server/tool/lsp_spec/README.md + +## Architecture + +- `main/lib/language-services/manager.ts` + - Registers providers + - Tracks provider enable/disable state + - Produces workspace snapshots for the Problems view +- `main/lib/language-services/diagnostics-store.ts` + - Holds normalized diagnostics per provider/file/workspace +- `main/lib/language-services/lsp/StdioJsonRpcClient.ts` + - Shared stdio JSON-RPC transport for LSP-based providers +- `renderer/providers/LanguageServicesProvider` + - Syncs open editor documents to enabled providers +- `renderer/routes/_authenticated/settings/behavior/components/DiagnosticsSettings` + - Lets users toggle providers on or off + +## Adding a New Provider + +1. Implement `LanguageServiceProvider`. +2. Normalize diagnostics into `LanguageServiceDiagnostic`. +3. Register the provider in `LanguageServiceManager`. +4. Add a renderer-side language mapping in `LanguageServicesProvider`. +5. Add syntax highlighting support if needed in `detect-language.ts` and `loadLanguageSupport.ts`. +6. Extend the settings store/provider ID union if the provider should be user-toggleable. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c5361fdb94a..45e607d54ef 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -18,7 +18,7 @@ "start": "electron-vite preview", "generate:icons": "bun run scripts/generate-file-icons.ts", "predev": "cross-env NODE_ENV=development bun run clean:dev && bun run generate:icons && cross-env NODE_ENV=development bun run scripts/clean-launch-services.ts && cross-env NODE_ENV=development bun run scripts/patch-dev-protocol.ts", - "dev": "cross-env NODE_ENV=development electron-vite dev --watch", + "dev": "cross-env NODE_ENV=development NODE_OPTIONS=--max-old-space-size=8192 electron-vite dev --watch", "compile:app": "cross-env NODE_OPTIONS=--max-old-space-size=8192 electron-vite build", "copy:native-modules": "bun run scripts/copy-native-modules.ts", "validate:native-runtime": "bun run scripts/validate-native-runtime.ts", @@ -99,6 +99,7 @@ "@tanstack/react-router": "^1.147.3", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.18", + "@taplo/lib": "^0.5.0", "@tiptap/core": "^3.17.1", "@tiptap/extension-blockquote": "^3.17.1", "@tiptap/extension-bold": "^3.17.1", @@ -187,6 +188,7 @@ "node-addon-api": "^7.1.0", "node-pty": "1.1.0", "os-locale": "^6.0.2", + "pg": "8.20.0", "pidtree": "^0.6.0", "pidusage": "^4.0.1", "posthog-js": "1.310.1", @@ -222,6 +224,8 @@ "use-resize-observer": "^9.1.0", "utf-8-validate": "^6.0.6", "uuid": "^13.0.0", + "vscode-json-languageservice": "^5.7.2", + "vscode-languageserver-textdocument": "^1.0.12", "zod": "^4.3.5", "zustand": "^5.0.8" }, @@ -238,6 +242,7 @@ "@types/http-proxy": "^1.17.17", "@types/lodash": "^4.17.20", "@types/node": "^24.9.1", + "@types/pg": "8.15.6", "@types/react": "~19.2.2", "@types/react-dom": "^19.2.3", "@types/react-syntax-highlighter": "^15.5.13", diff --git a/apps/desktop/src/lib/trpc/routers/databases/index.ts b/apps/desktop/src/lib/trpc/routers/databases/index.ts new file mode 100644 index 00000000000..099dd928750 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/databases/index.ts @@ -0,0 +1,871 @@ +import type { Stats } from "node:fs"; +import { stat } from "node:fs/promises"; +import path from "node:path"; +import { TRPCError } from "@trpc/server"; +import Database from "better-sqlite3"; +import fg from "fast-glob"; +import { Client } from "pg"; +import { z } from "zod"; +import { publicProcedure, router } from "../.."; + +const SQLITE_FILE_GLOBS = [ + "**/*.db", + "**/*.sqlite", + "**/*.sqlite3", + "**/*.db3", + "**/*.duckdb", +]; + +const SQLITE_ROW_ID_COLUMN = "__superset_rowid"; +const SQLITE_PRIMARY_KEY_COLUMN = "__superset_primary_key"; +const POSTGRES_ROW_ID_COLUMN = "__superset_ctid"; +const PREVIEW_TEXT_LIMIT = 180; + +function isAbsoluteFilesystemPath(inputPath: string): boolean { + return path.isAbsolute(inputPath) || /^[A-Za-z]:[\\/]/.test(inputPath); +} + +function ensureAbsoluteFilesystemPath(inputPath: string): void { + if (!isAbsoluteFilesystemPath(inputPath)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Database path must be absolute.", + }); + } +} + +async function ensureExistingFile(inputPath: string): Promise { + let metadata: Stats; + try { + metadata = await stat(inputPath); + } catch { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Database file not found: ${inputPath}`, + }); + } + + if (!metadata.isFile()) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Path is not a file: ${inputPath}`, + }); + } +} + +async function ensureExistingDirectory(inputPath: string): Promise { + let metadata: Stats; + try { + metadata = await stat(inputPath); + } catch { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Workspace path not found: ${inputPath}`, + }); + } + + if (!metadata.isDirectory()) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Path is not a directory: ${inputPath}`, + }); + } +} + +function quoteSqliteIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +function quotePostgresIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +function quoteSqlStringLiteral(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +function buildSqlitePreviewExpression( + columnName: string, + declaredType: string | null | undefined, +): string { + const quotedColumn = quoteSqliteIdentifier(columnName); + const normalizedType = (declaredType ?? "").toLowerCase(); + + if (normalizedType.includes("blob")) { + return `CASE WHEN ${quotedColumn} IS NULL THEN NULL ELSE '' END AS ${quoteSqliteIdentifier(columnName)}`; + } + + if ( + normalizedType.includes("text") || + normalizedType.includes("char") || + normalizedType.includes("clob") || + normalizedType.includes("json") || + normalizedType.length === 0 + ) { + return `CASE + WHEN ${quotedColumn} IS NULL THEN NULL + WHEN typeof(${quotedColumn}) = 'text' AND length(CAST(${quotedColumn} AS TEXT)) > ${PREVIEW_TEXT_LIMIT} + THEN substr(CAST(${quotedColumn} AS TEXT), 1, ${PREVIEW_TEXT_LIMIT}) || '…' + ELSE ${quotedColumn} + END AS ${quoteSqliteIdentifier(columnName)}`; + } + + return `${quotedColumn} AS ${quoteSqliteIdentifier(columnName)}`; +} + +function buildPostgresPreviewExpression(input: { + columnName: string; + dataType: string; + udtName: string; +}): string { + const quotedColumn = quotePostgresIdentifier(input.columnName); + const outputAlias = quotePostgresIdentifier(input.columnName); + const normalizedType = input.dataType.toLowerCase(); + const normalizedUdtName = input.udtName.toLowerCase(); + + if (normalizedType === "bytea") { + return `CASE WHEN ${quotedColumn} IS NULL THEN NULL ELSE '' END AS ${outputAlias}`; + } + + if (normalizedType === "json" || normalizedType === "jsonb") { + return `CASE + WHEN ${quotedColumn} IS NULL THEN NULL + ELSE '<${normalizedType}> ' || left(${quotedColumn}::text, ${PREVIEW_TEXT_LIMIT}) || + CASE WHEN length(${quotedColumn}::text) > ${PREVIEW_TEXT_LIMIT} THEN '…' ELSE '' END + END AS ${outputAlias}`; + } + + if (normalizedType === "array") { + return `CASE + WHEN ${quotedColumn} IS NULL THEN NULL + ELSE 'Array(' || coalesce(cardinality(${quotedColumn}), 0)::text || ') ' || + left(${quotedColumn}::text, ${PREVIEW_TEXT_LIMIT}) || + CASE WHEN length(${quotedColumn}::text) > ${PREVIEW_TEXT_LIMIT} THEN '…' ELSE '' END + END AS ${outputAlias}`; + } + + if ( + normalizedType === "text" || + normalizedType === "character varying" || + normalizedType === "character" || + normalizedType === "xml" || + normalizedType === "citext" || + normalizedType === "tsvector" || + normalizedType === "tsquery" || + normalizedUdtName === "vector" || + normalizedUdtName === "halfvec" || + normalizedUdtName === "sparsevec" || + normalizedUdtName === "geometry" || + normalizedUdtName === "geography" || + normalizedUdtName === "hstore" + ) { + return `CASE + WHEN ${quotedColumn} IS NULL THEN NULL + WHEN length(${quotedColumn}::text) > ${PREVIEW_TEXT_LIMIT} + THEN left(${quotedColumn}::text, ${PREVIEW_TEXT_LIMIT}) || '…' + ELSE ${quotedColumn}::text + END AS ${outputAlias}`; + } + + return `${quotedColumn} AS ${outputAlias}`; +} + +function getSqliteTableMetadata( + db: Database.Database, + tableName: string, +): { + columns: Array<{ + cid: number; + name: string; + type: string | null; + notnull: 0 | 1; + dflt_value: string | null; + pk: number; + }>; + primaryKeyColumns: Array<{ + cid: number; + name: string; + type: string | null; + notnull: 0 | 1; + dflt_value: string | null; + pk: number; + }>; + hasRowId: boolean; +} { + const columns = db + .prepare(`PRAGMA table_info(${quoteSqliteIdentifier(tableName)})`) + .all() as Array<{ + cid: number; + name: string; + type: string | null; + notnull: 0 | 1; + dflt_value: string | null; + pk: number; + }>; + const primaryKeyColumns = columns + .filter((column) => column.pk > 0) + .sort((left, right) => left.pk - right.pk); + const tableDefinition = db + .prepare( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1", + ) + .get(tableName) as { sql?: string | null } | undefined; + + return { + columns, + primaryKeyColumns, + hasRowId: !/without\s+rowid/i.test(tableDefinition?.sql ?? ""), + }; +} + +function buildSqlitePrimaryKeyPreviewExpression( + primaryKeyColumns: Array<{ name: string }>, +): string { + if (primaryKeyColumns.length === 0) { + return `NULL AS ${quoteSqliteIdentifier(SQLITE_PRIMARY_KEY_COLUMN)}`; + } + + const jsonEntries = primaryKeyColumns.flatMap((column) => [ + quoteSqlStringLiteral(column.name), + quoteSqliteIdentifier(column.name), + ]); + + return `json_object(${jsonEntries.join(", ")}) AS ${quoteSqliteIdentifier(SQLITE_PRIMARY_KEY_COLUMN)}`; +} + +function openSqliteDatabase(databasePath: string): Database.Database { + try { + return new Database(databasePath, { + fileMustExist: true, + }); + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + error instanceof Error + ? error.message + : "Failed to open SQLite database.", + }); + } +} + +async function withPostgresClient( + connectionString: string, + callback: (client: Client) => Promise, +): Promise { + const client = new Client({ connectionString }); + + try { + await client.connect(); + return await callback(client); + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + error instanceof Error + ? error.message + : "Failed to connect to PostgreSQL.", + }); + } finally { + await client.end().catch(() => undefined); + } +} + +function stripTrailingSemicolon(sql: string): string { + return sql.replace(/;\s*$/, ""); +} + +function canApplyPostgresReadLimit(sql: string): boolean { + return /^(select|with|values|table)\b/i.test(sql.trim()); +} + +export const createDatabasesRouter = () => { + return router({ + discoverSqliteFiles: publicProcedure + .input( + z.object({ + worktreePath: z.string().min(1), + limit: z.number().int().positive().max(200).optional(), + }), + ) + .query(async ({ input }) => { + ensureAbsoluteFilesystemPath(input.worktreePath); + await ensureExistingDirectory(input.worktreePath); + + const limit = input.limit ?? 50; + const files = await fg(SQLITE_FILE_GLOBS, { + absolute: true, + cwd: input.worktreePath, + onlyFiles: true, + unique: true, + suppressErrors: true, + ignore: [ + "**/.git/**", + "**/.next/**", + "**/.turbo/**", + "**/dist/**", + "**/node_modules/**", + ], + }); + + return { + files: files + .sort((left, right) => left.localeCompare(right)) + .slice(0, limit) + .map((absolutePath) => ({ + absolutePath, + relativePath: path.relative(input.worktreePath, absolutePath), + })), + }; + }), + + inspectSqlite: publicProcedure + .input( + z.object({ + databasePath: z.string().min(1), + }), + ) + .query(async ({ input }) => { + ensureAbsoluteFilesystemPath(input.databasePath); + await ensureExistingFile(input.databasePath); + + const db = openSqliteDatabase(input.databasePath); + + try { + const tables = db + .prepare( + ` + SELECT name, type + FROM sqlite_master + WHERE type IN ('table', 'view') + AND name NOT LIKE 'sqlite_%' + ORDER BY type, name + `, + ) + .all() as Array<{ + name: string; + type: "table" | "view"; + }>; + + return { + tables: tables.map((table) => ({ + schema: null, + name: table.name, + type: table.type, + columns: db + .prepare( + `PRAGMA table_info(${quoteSqliteIdentifier(table.name)})`, + ) + .all() as Array<{ + cid: number; + name: string; + type: string; + notnull: 0 | 1; + dflt_value: string | null; + pk: 0 | 1; + }>, + })), + }; + } finally { + db.close(); + } + }), + + inspectPostgres: publicProcedure + .input( + z.object({ + connectionString: z.string().min(1), + }), + ) + .query(async ({ input }) => { + return await withPostgresClient( + input.connectionString, + async (client) => { + const result = await client.query<{ + table_schema: string; + table_name: string; + table_type: string; + column_name: string; + data_type: string; + is_nullable: "YES" | "NO"; + ordinal_position: number; + }>(` + SELECT + t.table_schema, + t.table_name, + t.table_type, + c.column_name, + c.data_type, + c.is_nullable, + c.ordinal_position + FROM information_schema.tables t + JOIN information_schema.columns c + ON t.table_schema = c.table_schema + AND t.table_name = c.table_name + WHERE t.table_schema NOT IN ('pg_catalog', 'information_schema') + ORDER BY t.table_schema, t.table_name, c.ordinal_position + `); + + const tables = new Map< + string, + { + schema: string; + name: string; + type: string; + columns: { + cid: number; + name: string; + type: string; + notnull: 0 | 1; + dflt_value: string | null; + pk: 0 | 1; + }[]; + } + >(); + + for (const row of result.rows) { + const key = `${row.table_schema}.${row.table_name}`; + const current: + | { + schema: string; + name: string; + type: string; + columns: { + cid: number; + name: string; + type: string; + notnull: 0 | 1; + dflt_value: string | null; + pk: 0 | 1; + }[]; + } + | undefined = tables.get(key); + const nextTable = current ?? { + schema: row.table_schema, + name: row.table_name, + type: row.table_type.toLowerCase(), + columns: [] as { + cid: number; + name: string; + type: string; + notnull: 0 | 1; + dflt_value: string | null; + pk: 0 | 1; + }[], + }; + nextTable.columns.push({ + cid: row.ordinal_position, + name: row.column_name, + type: row.data_type, + notnull: row.is_nullable === "NO" ? 1 : 0, + dflt_value: null, + pk: 0, + }); + tables.set(key, nextTable); + } + + return { + tables: Array.from(tables.values()), + }; + }, + ); + }), + + previewSqliteTable: publicProcedure + .input( + z.object({ + databasePath: z.string().min(1), + tableName: z.string().min(1), + limit: z.number().int().positive().max(200).optional(), + offset: z.number().int().min(0).optional(), + }), + ) + .query(async ({ input }) => { + try { + ensureAbsoluteFilesystemPath(input.databasePath); + await ensureExistingFile(input.databasePath); + + const db = openSqliteDatabase(input.databasePath); + const limit = input.limit ?? 50; + const offset = input.offset ?? 0; + const startedAt = performance.now(); + try { + const metadata = getSqliteTableMetadata(db, input.tableName); + const previewSelect = metadata.columns + .map((column) => + buildSqlitePreviewExpression(column.name, column.type), + ) + .join(", "); + const selectColumns = [ + metadata.hasRowId + ? `rowid AS ${quoteSqliteIdentifier(SQLITE_ROW_ID_COLUMN)}` + : null, + buildSqlitePrimaryKeyPreviewExpression( + metadata.primaryKeyColumns, + ), + previewSelect, + ].filter(Boolean); + const statement = db.prepare( + `SELECT ${selectColumns.join(", ")} FROM ${quoteSqliteIdentifier(input.tableName)} LIMIT ? OFFSET ?`, + ); + const previewRows = statement.all(limit + 1, offset) as Array< + Record + >; + const hasMore = previewRows.length > limit; + const rows = hasMore ? previewRows.slice(0, limit) : previewRows; + + return { + columns: statement + .columns() + .map((column) => column.name) + .filter( + (column) => + column !== SQLITE_ROW_ID_COLUMN && + column !== SQLITE_PRIMARY_KEY_COLUMN, + ), + rows, + rowCount: rows.length, + totalRows: null, + hasMore, + offset, + limit, + elapsedMs: Math.round(performance.now() - startedAt), + }; + } finally { + db.close(); + } + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + error instanceof Error + ? error.message + : "Failed to preview SQLite table.", + }); + } + }), + + getSqliteRowDetail: publicProcedure + .input( + z.object({ + databasePath: z.string().min(1), + tableName: z.string().min(1), + rowId: z.union([z.string(), z.number()]).optional(), + primaryKey: z.string().optional(), + }), + ) + .query(async ({ input }) => { + ensureAbsoluteFilesystemPath(input.databasePath); + await ensureExistingFile(input.databasePath); + + const db = openSqliteDatabase(input.databasePath); + try { + const metadata = getSqliteTableMetadata(db, input.tableName); + let whereClause = ""; + const parameters: Array = []; + + if (metadata.primaryKeyColumns.length > 0) { + if (!input.primaryKey) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "Primary key payload is required for this SQLite table.", + }); + } + + let parsedPrimaryKey: Record; + try { + parsedPrimaryKey = JSON.parse(input.primaryKey) as Record< + string, + unknown + >; + } catch { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Invalid SQLite primary key payload.", + }); + } + whereClause = metadata.primaryKeyColumns + .map((column) => { + const value = parsedPrimaryKey[column.name]; + if (value === null) { + return `${quoteSqliteIdentifier(column.name)} IS NULL`; + } + parameters.push((value ?? null) as string | number | null); + return `${quoteSqliteIdentifier(column.name)} = ?`; + }) + .join(" AND "); + } else if (metadata.hasRowId) { + if (input.rowId === undefined) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "rowid is required for this SQLite table.", + }); + } + whereClause = "rowid = ?"; + parameters.push(input.rowId); + } else { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "This SQLite table has neither a rowid nor a primary key.", + }); + } + + const row = db + .prepare( + `SELECT * FROM ${quoteSqliteIdentifier(input.tableName)} WHERE ${whereClause} LIMIT 1`, + ) + .get(...parameters) as Record | undefined; + + if (!row) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Row not found.", + }); + } + + return { row }; + } finally { + db.close(); + } + }), + + previewPostgresTable: publicProcedure + .input( + z.object({ + connectionString: z.string().min(1), + schema: z.string().min(1), + tableName: z.string().min(1), + limit: z.number().int().positive().max(200).optional(), + offset: z.number().int().min(0).optional(), + }), + ) + .query(async ({ input }) => { + const limit = input.limit ?? 50; + const offset = input.offset ?? 0; + const startedAt = performance.now(); + + return await withPostgresClient( + input.connectionString, + async (client) => { + const columnInfo = await client.query<{ + column_name: string; + data_type: string; + udt_name: string; + ordinal_position: number; + }>( + ` + SELECT column_name, data_type, udt_name, ordinal_position + FROM information_schema.columns + WHERE table_schema = $1 AND table_name = $2 + ORDER BY ordinal_position + `, + [input.schema, input.tableName], + ); + const qualifiedTableName = `${quotePostgresIdentifier(input.schema)}.${quotePostgresIdentifier(input.tableName)}`; + const previewSelect = columnInfo.rows + .map((column) => + buildPostgresPreviewExpression({ + columnName: column.column_name, + dataType: column.data_type, + udtName: column.udt_name, + }), + ) + .join(", "); + const dataResult = await client.query( + `SELECT ctid::text AS ${quotePostgresIdentifier(POSTGRES_ROW_ID_COLUMN)}, ${previewSelect} FROM ${qualifiedTableName} LIMIT $1 OFFSET $2`, + [limit + 1, offset], + ); + const hasMore = dataResult.rows.length > limit; + const rows = hasMore + ? dataResult.rows.slice(0, limit) + : dataResult.rows; + + return { + columns: dataResult.fields + .map((field: { name: string }) => field.name) + .filter((column) => column !== POSTGRES_ROW_ID_COLUMN), + rows, + rowCount: rows.length, + totalRows: null, + hasMore, + offset, + limit, + elapsedMs: Math.round(performance.now() - startedAt), + }; + }, + ); + }), + + getPostgresRowDetail: publicProcedure + .input( + z.object({ + connectionString: z.string().min(1), + schema: z.string().min(1), + tableName: z.string().min(1), + ctid: z.string().min(1), + }), + ) + .query(async ({ input }) => { + return await withPostgresClient( + input.connectionString, + async (client) => { + const qualifiedTableName = `${quotePostgresIdentifier(input.schema)}.${quotePostgresIdentifier(input.tableName)}`; + const result = await client.query( + `SELECT ctid::text AS ${quotePostgresIdentifier(POSTGRES_ROW_ID_COLUMN)}, * FROM ${qualifiedTableName} WHERE ctid = $1::tid LIMIT 1`, + [input.ctid], + ); + + const row = result.rows[0] as Record | undefined; + if (!row) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Row not found.", + }); + } + + return { row }; + }, + ); + }), + + executeSqlite: publicProcedure + .input( + z.object({ + databasePath: z.string().min(1), + sql: z.string().min(1), + limit: z.number().int().positive().max(1000).optional(), + }), + ) + .mutation(async ({ input }) => { + ensureAbsoluteFilesystemPath(input.databasePath); + await ensureExistingFile(input.databasePath); + + const sql = input.sql.trim(); + if (!sql) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "SQL is required.", + }); + } + + const db = openSqliteDatabase(input.databasePath); + const startedAt = performance.now(); + + try { + const statement = db.prepare(sql); + const limit = input.limit ?? 200; + + if (!statement.reader) { + const result = statement.run(); + return { + columns: [] as string[], + rows: [] as Array>, + rowCount: result.changes, + truncated: false, + elapsedMs: Math.round(performance.now() - startedAt), + command: "write", + lastInsertRowid: + typeof result.lastInsertRowid === "bigint" + ? result.lastInsertRowid.toString() + : result.lastInsertRowid, + }; + } + + const rows: Array> = []; + let truncated = false; + for (const row of statement.iterate() as Iterable< + Record + >) { + if (rows.length >= limit) { + truncated = true; + break; + } + rows.push(row); + } + + return { + columns: statement.columns().map((column) => column.name), + rows, + rowCount: rows.length, + truncated, + elapsedMs: Math.round(performance.now() - startedAt), + command: "read", + }; + } catch (error) { + if (error instanceof TRPCError) { + throw error; + } + + throw new TRPCError({ + code: "BAD_REQUEST", + message: + error instanceof Error ? error.message : "Failed to execute SQL.", + }); + } finally { + db.close(); + } + }), + + executePostgres: publicProcedure + .input( + z.object({ + connectionString: z.string().min(1), + sql: z.string().min(1), + limit: z.number().int().positive().max(1000).optional(), + }), + ) + .mutation(async ({ input }) => { + const sql = input.sql.trim(); + if (!sql) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "SQL is required.", + }); + } + + const startedAt = performance.now(); + return await withPostgresClient( + input.connectionString, + async (client) => { + const limit = input.limit ?? 200; + if (canApplyPostgresReadLimit(sql)) { + const limitedSql = `SELECT * FROM (${stripTrailingSemicolon( + sql, + )}) AS __superset_query LIMIT ${limit + 1}`; + const limitedResult = await client.query(limitedSql); + const truncated = limitedResult.rows.length > limit; + const rows = truncated + ? limitedResult.rows.slice(0, limit) + : limitedResult.rows; + + return { + columns: limitedResult.fields.map( + (field: { name: string }) => field.name, + ), + rows, + rowCount: rows.length, + truncated, + elapsedMs: Math.round(performance.now() - startedAt), + command: "SELECT", + }; + } + + const result = await client.query(sql); + + return { + columns: result.fields.map( + (field: { name: string }) => field.name, + ), + rows: result.rows.slice(0, limit), + rowCount: result.rowCount ?? result.rows.length, + truncated: result.rows.length > limit, + elapsedMs: Math.round(performance.now() - startedAt), + command: result.command, + }; + }, + ); + }), + }); +}; diff --git a/apps/desktop/src/lib/trpc/routers/diagnostics/index.ts b/apps/desktop/src/lib/trpc/routers/diagnostics/index.ts new file mode 100644 index 00000000000..e68297cdcf1 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/diagnostics/index.ts @@ -0,0 +1,562 @@ +import path from "node:path"; +import { TRPCError } from "@trpc/server"; +import * as ts from "typescript"; +import { z } from "zod"; +import { publicProcedure, router } from "../.."; +import { getWorkspace } from "../workspaces/utils/db-helpers"; +import { getWorkspacePath } from "../workspaces/utils/worktree"; + +const MAX_PROBLEMS = 500; + +const openDocumentSchema = z.object({ + relativePath: z.string(), + content: z.string().nullable(), +}); + +const typeScriptProblemSchema = z.object({ + relativePath: z.string().nullable(), + line: z.number().nullable(), + column: z.number().nullable(), + endLine: z.number().nullable(), + endColumn: z.number().nullable(), + message: z.string(), + code: z.union([z.string(), z.number()]).nullable(), + severity: z.enum(["error", "warning", "info", "hint"]), + source: z.string(), +}); + +function resolveConfigPath(workspacePath: string): string | null { + const tsconfigPath = path.join(workspacePath, "tsconfig.json"); + if (ts.sys.fileExists(tsconfigPath)) { + return tsconfigPath; + } + + const jsconfigPath = path.join(workspacePath, "jsconfig.json"); + if (ts.sys.fileExists(jsconfigPath)) { + return jsconfigPath; + } + + return null; +} + +function findNearestConfigPath( + workspacePath: string, + relativePath: string, +): string | null { + let currentDirectory = path.resolve( + workspacePath, + path.dirname(relativePath), + ); + const normalizedWorkspacePath = path.resolve(workspacePath); + + while (true) { + const tsconfigPath = path.join(currentDirectory, "tsconfig.json"); + if (ts.sys.fileExists(tsconfigPath)) { + return tsconfigPath; + } + + const jsconfigPath = path.join(currentDirectory, "jsconfig.json"); + if (ts.sys.fileExists(jsconfigPath)) { + return jsconfigPath; + } + + if (currentDirectory === normalizedWorkspacePath) { + return null; + } + + const parentDirectory = path.dirname(currentDirectory); + if (parentDirectory === currentDirectory) { + return null; + } + + currentDirectory = parentDirectory; + } +} + +function mapSeverity( + category: ts.DiagnosticCategory, +): "error" | "warning" | "info" | "hint" { + switch (category) { + case ts.DiagnosticCategory.Error: + return "error"; + case ts.DiagnosticCategory.Warning: + return "warning"; + case ts.DiagnosticCategory.Suggestion: + return "hint"; + default: + return "info"; + } +} + +function normalizeRelativePath( + workspacePath: string, + fileName: string, +): string | null { + const relativePath = path.relative(workspacePath, fileName); + if ( + !relativePath || + relativePath.startsWith("..") || + path.isAbsolute(relativePath) + ) { + return null; + } + + return relativePath.split(path.sep).join("/"); +} + +function diagnosticSortValue(severity: string): number { + switch (severity) { + case "error": + return 0; + case "warning": + return 1; + case "info": + return 2; + default: + return 3; + } +} + +function createOpenDocumentMap( + workspacePath: string, + openDocuments: Array<{ relativePath: string; content: string | null }>, +): Map { + return openDocuments.reduce((map, document) => { + if (document.content === null) { + return map; + } + + map.set( + path.resolve(workspacePath, document.relativePath), + document.content, + ); + return map; + }, new Map()); +} + +function createCompilerHostWithOpenDocuments( + options: ts.CompilerOptions, + openDocumentMap: Map, +): ts.CompilerHost { + const compilerHost = ts.createCompilerHost(options, true); + const originalReadFile = compilerHost.readFile.bind(compilerHost); + const originalFileExists = compilerHost.fileExists.bind(compilerHost); + const originalGetSourceFile = compilerHost.getSourceFile.bind(compilerHost); + + compilerHost.readFile = (fileName) => { + const override = openDocumentMap.get(path.resolve(fileName)); + if (override !== undefined) { + return override; + } + + return originalReadFile(fileName); + }; + + compilerHost.fileExists = (fileName) => { + if (openDocumentMap.has(path.resolve(fileName))) { + return true; + } + + return originalFileExists(fileName); + }; + + compilerHost.getSourceFile = ( + fileName, + languageVersionOrOptions, + onError, + shouldCreateNewSourceFile, + ) => { + const override = openDocumentMap.get(path.resolve(fileName)); + if (override !== undefined) { + return ts.createSourceFile( + fileName, + override, + languageVersionOrOptions, + true, + ); + } + + return originalGetSourceFile( + fileName, + languageVersionOrOptions, + onError, + shouldCreateNewSourceFile, + ); + }; + + return compilerHost; +} + +function getStandaloneCompilerOptions(filePath: string): ts.CompilerOptions { + const extension = path.extname(filePath).toLowerCase(); + return { + noEmit: true, + allowJs: [".js", ".jsx", ".mjs", ".cjs"].includes(extension), + checkJs: [".js", ".jsx", ".mjs", ".cjs"].includes(extension), + jsx: [".jsx", ".tsx"].includes(extension) ? ts.JsxEmit.Preserve : undefined, + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.ESNext, + skipLibCheck: true, + moduleResolution: ts.ModuleResolutionKind.Bundler, + }; +} + +function createProblemKey(problem: { + relativePath: string | null; + line: number | null; + column: number | null; + message: string; + code: string | number | null; + severity: string; + source: string; +}): string { + return [ + problem.relativePath ?? "workspace", + problem.line ?? 0, + problem.column ?? 0, + problem.code ?? "no-code", + problem.severity, + problem.source, + problem.message, + ].join("::"); +} + +function mapDiagnosticsToProblems( + diagnostics: readonly ts.Diagnostic[], + workspacePath: string, +) { + return diagnostics + .map((diagnostic) => { + const message = ts.flattenDiagnosticMessageText( + diagnostic.messageText, + "\n", + ); + const severity = mapSeverity(diagnostic.category); + const relativePath = diagnostic.file?.fileName + ? normalizeRelativePath(workspacePath, diagnostic.file.fileName) + : null; + + if (diagnostic.file?.fileName && relativePath === null) { + return null; + } + + const start = + diagnostic.file && typeof diagnostic.start === "number" + ? diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start) + : null; + const end = + diagnostic.file && + typeof diagnostic.start === "number" && + typeof diagnostic.length === "number" + ? diagnostic.file.getLineAndCharacterOfPosition( + diagnostic.start + diagnostic.length, + ) + : null; + + return { + relativePath, + line: start ? start.line + 1 : null, + column: start ? start.character + 1 : null, + endLine: end ? end.line + 1 : null, + endColumn: end ? end.character + 1 : null, + message, + code: diagnostic.code ?? null, + severity, + source: "typescript", + }; + }) + .filter( + (problem): problem is NonNullable => problem !== null, + ); +} + +function filterProblemsForOpenDocuments( + problems: Array>, + openDocuments: Array<{ relativePath: string; content: string | null }>, +) { + if (openDocuments.length === 0) { + return problems; + } + + const openDocumentPaths = new Set( + openDocuments.map((document) => document.relativePath), + ); + + return problems.filter((problem) => { + if (problem.relativePath === null) { + return false; + } + + return openDocumentPaths.has(problem.relativePath); + }); +} + +export const createDiagnosticsRouter = () => { + return router({ + getTypeScriptProblems: publicProcedure + .input( + z.object({ + workspaceId: z.string(), + openDocuments: z.array(openDocumentSchema).default([]), + }), + ) + .output( + z.object({ + status: z.enum(["ready", "no-config"]), + workspacePath: z.string(), + configPath: z.string().nullable(), + problems: z.array(typeScriptProblemSchema), + totalCount: z.number(), + truncated: z.boolean(), + summary: z.object({ + errorCount: z.number(), + warningCount: z.number(), + infoCount: z.number(), + hintCount: z.number(), + }), + }), + ) + .query(({ input }) => { + const workspace = getWorkspace(input.workspaceId); + if (!workspace) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Workspace ${input.workspaceId} not found`, + }); + } + + const workspacePath = getWorkspacePath(workspace); + if (!workspacePath) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: `Workspace ${input.workspaceId} has no filesystem path`, + }); + } + + const rootConfigPath = resolveConfigPath(workspacePath); + const configPaths = new Set(); + const standaloneFiles: string[] = []; + const openDocumentMap = createOpenDocumentMap( + workspacePath, + input.openDocuments, + ); + + if (input.openDocuments.length > 0) { + for (const document of input.openDocuments) { + const configPath = findNearestConfigPath( + workspacePath, + document.relativePath, + ); + if (configPath) { + configPaths.add(configPath); + } else { + standaloneFiles.push( + path.resolve(workspacePath, document.relativePath), + ); + } + } + } else if (rootConfigPath) { + configPaths.add(rootConfigPath); + } + + if (configPaths.size === 0 && standaloneFiles.length === 0) { + console.log("[diagnostics] no config found", { + workspaceId: input.workspaceId, + workspacePath, + openDocuments: input.openDocuments.map( + (document) => document.relativePath, + ), + }); + return { + status: "no-config" as const, + workspacePath, + configPath: null, + problems: [], + totalCount: 0, + truncated: false, + summary: { + errorCount: 0, + warningCount: 0, + infoCount: 0, + hintCount: 0, + }, + }; + } + + const collectedProblems = new Map< + string, + z.infer + >(); + const configPathList = Array.from(configPaths); + + console.log("[diagnostics] target documents", { + workspaceId: input.workspaceId, + workspacePath, + openDocuments: input.openDocuments.map((document) => ({ + relativePath: document.relativePath, + hasOverride: document.content !== null, + })), + configPaths: configPathList, + standaloneFiles: standaloneFiles.map((filePath) => + normalizeRelativePath(workspacePath, filePath), + ), + }); + + for (const configPath of configPathList) { + const configFile = ts.readConfigFile(configPath, ts.sys.readFile); + if (configFile.error) { + const problem = { + relativePath: normalizeRelativePath(workspacePath, configPath), + line: null, + column: null, + endLine: null, + endColumn: null, + message: ts.flattenDiagnosticMessageText( + configFile.error.messageText, + "\n", + ), + code: configFile.error.code, + severity: mapSeverity(configFile.error.category), + source: "typescript", + }; + collectedProblems.set(createProblemKey(problem), problem); + continue; + } + + const parsedConfig = ts.parseJsonConfigFileContent( + configFile.config, + ts.sys, + path.dirname(configPath), + { noEmit: true }, + configPath, + ); + const configOpenFiles = input.openDocuments + .filter( + (document) => + findNearestConfigPath(workspacePath, document.relativePath) === + configPath, + ) + .map((document) => + path.resolve(workspacePath, document.relativePath), + ); + const rootNames = Array.from( + new Set([...parsedConfig.fileNames, ...configOpenFiles]), + ); + console.log("[diagnostics] parsed config", { + workspaceId: input.workspaceId, + workspacePath, + configPath, + openDocumentCount: input.openDocuments.length, + rootFileCount: rootNames.length, + sampleRootFiles: rootNames + .slice(0, 20) + .map((fileName) => + normalizeRelativePath(workspacePath, fileName), + ), + }); + + const compilerHost = createCompilerHostWithOpenDocuments( + parsedConfig.options, + openDocumentMap, + ); + const program = ts.createProgram({ + rootNames, + options: parsedConfig.options, + projectReferences: parsedConfig.projectReferences, + host: compilerHost, + }); + const diagnostics = [ + ...parsedConfig.errors, + ...ts.getPreEmitDiagnostics(program), + ]; + for (const problem of mapDiagnosticsToProblems( + diagnostics, + workspacePath, + )) { + collectedProblems.set(createProblemKey(problem), problem); + } + } + + for (const standaloneFilePath of standaloneFiles) { + const compilerOptions = + getStandaloneCompilerOptions(standaloneFilePath); + const compilerHost = createCompilerHostWithOpenDocuments( + compilerOptions, + openDocumentMap, + ); + const program = ts.createProgram({ + rootNames: [standaloneFilePath], + options: compilerOptions, + host: compilerHost, + }); + for (const problem of mapDiagnosticsToProblems( + ts.getPreEmitDiagnostics(program), + workspacePath, + )) { + collectedProblems.set(createProblemKey(problem), problem); + } + } + + const mappedProblems = filterProblemsForOpenDocuments( + Array.from(collectedProblems.values()), + input.openDocuments, + ).sort((left, right) => { + const severityDiff = + diagnosticSortValue(left.severity) - + diagnosticSortValue(right.severity); + if (severityDiff !== 0) { + return severityDiff; + } + + const pathDiff = (left.relativePath ?? "").localeCompare( + right.relativePath ?? "", + ); + if (pathDiff !== 0) { + return pathDiff; + } + return (left.line ?? 0) - (right.line ?? 0); + }); + + const summary = mappedProblems.reduce( + (acc, problem) => { + if (problem.severity === "error") acc.errorCount += 1; + if (problem.severity === "warning") acc.warningCount += 1; + if (problem.severity === "info") acc.infoCount += 1; + if (problem.severity === "hint") acc.hintCount += 1; + return acc; + }, + { + errorCount: 0, + warningCount: 0, + infoCount: 0, + hintCount: 0, + }, + ); + + console.log("[diagnostics] result", { + workspaceId: input.workspaceId, + configPaths: configPathList, + totalCount: mappedProblems.length, + problemFiles: Array.from( + new Set( + mappedProblems.map( + (problem) => problem.relativePath ?? "Workspace", + ), + ), + ), + }); + + return { + status: "ready" as const, + workspacePath, + configPath: configPathList.length === 1 ? configPathList[0] : null, + problems: mappedProblems.slice(0, MAX_PROBLEMS), + totalCount: mappedProblems.length, + truncated: mappedProblems.length > MAX_PROBLEMS, + summary, + }; + }), + }); +}; + +export type DiagnosticsRouter = ReturnType; diff --git a/apps/desktop/src/lib/trpc/routers/filesystem/index.ts b/apps/desktop/src/lib/trpc/routers/filesystem/index.ts index 933b4dfaeeb..539f068d31a 100644 --- a/apps/desktop/src/lib/trpc/routers/filesystem/index.ts +++ b/apps/desktop/src/lib/trpc/routers/filesystem/index.ts @@ -28,6 +28,29 @@ type WatchPathEventBatch = { }>; }; +const searchContentInputSchema = z.object({ + workspaceId: z.string(), + query: z.string(), + includeHidden: z.boolean().optional(), + includePattern: z.string().optional(), + excludePattern: z.string().optional(), + limit: z.number().optional(), + isRegex: z.boolean().optional(), + caseSensitive: z.boolean().optional(), +}); + +const replaceContentInputSchema = z.object({ + workspaceId: z.string(), + query: z.string(), + replacement: z.string(), + includeHidden: z.boolean().optional(), + includePattern: z.string().optional(), + excludePattern: z.string().optional(), + isRegex: z.boolean().optional(), + caseSensitive: z.boolean().optional(), + paths: z.array(z.string()).optional(), +}); + export const createFilesystemRouter = () => { return router({ listDirectory: publicProcedure @@ -215,16 +238,7 @@ export const createFilesystemRouter = () => { }), searchContent: publicProcedure - .input( - z.object({ - workspaceId: z.string(), - query: z.string(), - includeHidden: z.boolean().optional(), - includePattern: z.string().optional(), - excludePattern: z.string().optional(), - limit: z.number().optional(), - }), - ) + .input(searchContentInputSchema) .query(async ({ input }) => { const trimmedQuery = input.query.trim(); if (!trimmedQuery) { @@ -238,6 +252,34 @@ export const createFilesystemRouter = () => { includePattern: input.includePattern, excludePattern: input.excludePattern, limit: input.limit, + isRegex: input.isRegex, + caseSensitive: input.caseSensitive, + }); + }), + + replaceContent: publicProcedure + .input(replaceContentInputSchema) + .mutation(async ({ input }) => { + if (input.query.length === 0) { + return { + replacements: 0, + filesUpdated: 0, + updated: [], + conflicts: [], + failed: [], + }; + } + + const service = getServiceForWorkspace(input.workspaceId); + return await service.replaceContent({ + query: input.query, + replacement: input.replacement, + includeHidden: input.includeHidden, + includePattern: input.includePattern, + excludePattern: input.excludePattern, + isRegex: input.isRegex, + caseSensitive: input.caseSensitive, + paths: input.paths, }); }), diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index 3112bba4e28..1fac06958d6 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -11,11 +11,14 @@ import { createChangesRouter } from "./changes"; import { createChatRuntimeServiceRouter } from "./chat-runtime-service"; import { createChatServiceRouter } from "./chat-service"; import { createConfigRouter } from "./config"; +import { createDatabasesRouter } from "./databases"; +import { createDiagnosticsRouter } from "./diagnostics"; import { createExtensionsRouter } from "./extensions"; import { createExternalRouter } from "./external"; import { createFilesystemRouter } from "./filesystem"; import { createHostServiceManagerRouter } from "./host-service-manager"; import { createHotkeysRouter } from "./hotkeys"; +import { createLanguageServicesRouter } from "./language-services"; import { createMenuRouter } from "./menu"; import { createModelProvidersRouter } from "./model-providers"; import { createNotificationsRouter } from "./notifications"; @@ -57,9 +60,12 @@ export const createAppRouter = ( resourceMetrics: createResourceMetricsRouter(), menu: createMenuRouter(), hotkeys: createHotkeysRouter(getWindow), + languageServices: createLanguageServicesRouter(), external: createExternalRouter(), settings: createSettingsRouter(), config: createConfigRouter(), + databases: createDatabasesRouter(), + diagnostics: createDiagnosticsRouter(), uiState: createUiStateRouter(), ringtone: createRingtoneRouter(getWindow), hostServiceManager: createHostServiceManagerRouter(), diff --git a/apps/desktop/src/lib/trpc/routers/language-services/index.ts b/apps/desktop/src/lib/trpc/routers/language-services/index.ts new file mode 100644 index 00000000000..e9985be940d --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/language-services/index.ts @@ -0,0 +1,154 @@ +import { TRPCError } from "@trpc/server"; +import { observable } from "@trpc/server/observable"; +import { languageServiceManager } from "main/lib/language-services/manager"; +import { z } from "zod"; +import { publicProcedure, router } from "../.."; +import { getWorkspace } from "../workspaces/utils/db-helpers"; +import { getWorkspacePath } from "../workspaces/utils/worktree"; + +const languageServiceDocumentSchema = z.object({ + workspaceId: z.string(), + absolutePath: z.string(), + languageId: z.string(), + content: z.string(), + version: z.number().int().nonnegative(), +}); + +function resolveWorkspacePath(workspaceId: string): string { + const workspace = getWorkspace(workspaceId); + if (!workspace) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Workspace ${workspaceId} not found`, + }); + } + + const workspacePath = getWorkspacePath(workspace); + if (!workspacePath) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: `Workspace ${workspaceId} has no filesystem path`, + }); + } + + return workspacePath; +} + +export const createLanguageServicesRouter = () => { + return router({ + openDocument: publicProcedure + .input(languageServiceDocumentSchema) + .mutation(async ({ input }) => { + const workspacePath = resolveWorkspacePath(input.workspaceId); + await languageServiceManager.openDocument({ + ...input, + workspacePath, + }); + return { ok: true }; + }), + + changeDocument: publicProcedure + .input(languageServiceDocumentSchema) + .mutation(async ({ input }) => { + const workspacePath = resolveWorkspacePath(input.workspaceId); + await languageServiceManager.syncDocument({ + ...input, + workspacePath, + }); + return { ok: true }; + }), + + closeDocument: publicProcedure + .input( + z.object({ + workspaceId: z.string(), + absolutePath: z.string(), + languageId: z.string(), + }), + ) + .mutation(async ({ input }) => { + const workspacePath = resolveWorkspacePath(input.workspaceId); + await languageServiceManager.closeDocument({ + ...input, + workspacePath, + }); + return { ok: true }; + }), + + refreshWorkspace: publicProcedure + .input( + z.object({ + workspaceId: z.string(), + }), + ) + .mutation(async ({ input }) => { + const workspacePath = resolveWorkspacePath(input.workspaceId); + await languageServiceManager.refreshWorkspace({ + workspaceId: input.workspaceId, + workspacePath, + }); + return { ok: true }; + }), + + getWorkspaceDiagnostics: publicProcedure + .input( + z.object({ + workspaceId: z.string(), + }), + ) + .query(({ input }) => { + const workspacePath = resolveWorkspacePath(input.workspaceId); + return languageServiceManager.getWorkspaceSnapshot({ + workspaceId: input.workspaceId, + workspacePath, + }); + }), + + getProviders: publicProcedure.query(() => { + return languageServiceManager.getProviders(); + }), + + setProviderEnabled: publicProcedure + .input( + z.object({ + providerId: z.string(), + enabled: z.boolean(), + }), + ) + .mutation(async ({ input }) => { + const provider = await languageServiceManager.setProviderEnabled( + input.providerId, + input.enabled, + ); + if (!provider) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Language service provider ${input.providerId} not found`, + }); + } + + return provider; + }), + + subscribeDiagnostics: publicProcedure + .input( + z.object({ + workspaceId: z.string(), + }), + ) + .subscription(({ input }) => { + return observable<{ version: number }>((emit) => { + const unsubscribe = languageServiceManager.subscribeToWorkspace( + input.workspaceId, + (payload) => { + emit.next(payload); + }, + ); + + return () => { + unsubscribe(); + }; + }); + }), + }); +}; diff --git a/apps/desktop/src/lib/trpc/routers/menu.ts b/apps/desktop/src/lib/trpc/routers/menu.ts index 7310d5f6a12..d775038d681 100644 --- a/apps/desktop/src/lib/trpc/routers/menu.ts +++ b/apps/desktop/src/lib/trpc/routers/menu.ts @@ -1,15 +1,18 @@ import { observable } from "@trpc/server/observable"; import { + type BrowserActionEvent, menuEmitter, type OpenSettingsEvent, type OpenWorkspaceEvent, type SettingsSection, } from "main/lib/menu-events"; +import type { BrowserShortcutAction } from "shared/browser-shortcuts"; import { publicProcedure, router } from ".."; type MenuEvent = | { type: "open-settings"; data: OpenSettingsEvent } - | { type: "open-workspace"; data: OpenWorkspaceEvent }; + | { type: "open-workspace"; data: OpenWorkspaceEvent } + | { type: "browser-action"; data: BrowserActionEvent }; export const createMenuRouter = () => { return router({ @@ -23,12 +26,18 @@ export const createMenuRouter = () => { emit.next({ type: "open-workspace", data: { workspaceId } }); }; + const onBrowserAction = (action: BrowserShortcutAction) => { + emit.next({ type: "browser-action", data: { action } }); + }; + menuEmitter.on("open-settings", onOpenSettings); menuEmitter.on("open-workspace", onOpenWorkspace); + menuEmitter.on("browser-action", onBrowserAction); return () => { menuEmitter.off("open-settings", onOpenSettings); menuEmitter.off("open-workspace", onOpenWorkspace); + menuEmitter.off("browser-action", onBrowserAction); }; }); }), diff --git a/apps/desktop/src/lib/trpc/routers/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts index 7e86917e93b..ad15a94444b 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/index.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts @@ -25,6 +25,7 @@ import { DEFAULT_CONFIRM_ON_QUIT, DEFAULT_FILE_OPEN_MODE, DEFAULT_OPEN_LINKS_IN_APP, + DEFAULT_PREVENT_AGENT_SLEEP, DEFAULT_SHOW_PRESETS_BAR, DEFAULT_SHOW_RESOURCE_MONITOR, DEFAULT_TERMINAL_LINK_BEHAVIOR, @@ -591,6 +592,26 @@ export const createSettingsRouter = () => { return { success: true }; }), + getPreventAgentSleep: publicProcedure.query(() => { + const row = getSettings(); + return row.preventAgentSleep ?? DEFAULT_PREVENT_AGENT_SLEEP; + }), + + setPreventAgentSleep: publicProcedure + .input(z.object({ enabled: z.boolean() })) + .mutation(({ input }) => { + localDb + .insert(settings) + .values({ id: 1, preventAgentSleep: input.enabled }) + .onConflictDoUpdate({ + target: settings.id, + set: { preventAgentSleep: input.enabled }, + }) + .run(); + + return { success: true }; + }), + getShowPresetsBar: publicProcedure.query(() => { const row = getSettings(); return row.showPresetsBar ?? DEFAULT_SHOW_PRESETS_BAR; diff --git a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts index 27fe6b71d2a..4710ed60be8 100644 --- a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts @@ -50,6 +50,7 @@ const paneSchema = z.object({ "chat", "devtools", "git-graph", + "database-explorer", ]), name: z.string(), isNew: z.boolean().optional(), @@ -93,6 +94,11 @@ const paneSchema = z.object({ targetPaneId: z.string(), }) .optional(), + databaseExplorer: z + .object({ + connectionId: z.string().nullable(), + }) + .optional(), workspaceRun: z .object({ workspaceId: z.string(), diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts index 91d2ce5dddb..7cb48317fdf 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts @@ -1,4 +1,5 @@ -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; import type { GitHubStatus } from "@superset/local-db"; import { workspaces, worktrees } from "@superset/local-db"; import { TRPCError } from "@trpc/server"; @@ -13,8 +14,10 @@ import { updateProjectDefaultBranch, } from "../utils/db-helpers"; import { + branchExistsOnRemote, fetchDefaultBranch, getAheadBehindCount, + getCurrentBranch, getDefaultBranch, listExternalWorktrees, refreshDefaultBranch, @@ -25,6 +28,7 @@ import { fetchCheckJobSteps, fetchGitHubPRComments, fetchGitHubPRStatus, + getRepoContext, type PullRequestCommentsTarget, } from "../utils/github"; import { GHIdentityCandidatesResponseSchema } from "../utils/github/types"; @@ -40,6 +44,229 @@ const gitHubPRCommentsInputSchema = z.object({ forceFresh: z.boolean().optional(), }); +const ghRepositoryPullRequestSchema = z.object({ + number: z.number(), + title: z.string(), + url: z.string(), + state: z.enum(["OPEN", "CLOSED", "MERGED"]), + isDraft: z.boolean().optional().default(false), + headRefName: z.string().optional(), + updatedAt: z.string().nullable().optional(), + author: z + .object({ + login: z.string().optional(), + }) + .nullable() + .optional(), +}); + +const ghRepositoryWorkflowSchema = z.object({ + id: z.number(), + name: z.string(), + path: z.string().optional(), + state: z.string().optional(), +}); + +const ghRepositoryWorkflowsResponseSchema = z.object({ + workflows: z.array(ghRepositoryWorkflowSchema).optional(), +}); + +const ghRepositoryWorkflowRunSchema = z.object({ + id: z.number(), + name: z.string().nullable().optional(), + display_title: z.string().nullable().optional(), + html_url: z.string().optional(), + status: z.string().nullable().optional(), + conclusion: z.string().nullable().optional(), + event: z.string().nullable().optional(), + created_at: z.string().nullable().optional(), + updated_at: z.string().nullable().optional(), + run_started_at: z.string().nullable().optional(), + head_branch: z.string().nullable().optional(), + head_sha: z.string().nullable().optional(), + run_number: z.number().optional(), + workflow_id: z.number().optional(), +}); + +const ghRepositoryWorkflowRunsResponseSchema = z.object({ + workflow_runs: z.array(ghRepositoryWorkflowRunSchema).optional(), +}); + +const ghRepositoryLabelSchema = z.object({ + name: z.string(), + color: z.string().optional(), + description: z.string().nullable().optional(), +}); + +const ghRepositoryAssigneeSchema = z.object({ + login: z.string(), + avatar_url: z.string().optional(), +}); + +function sanitizeIssueAssetBasename(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 80); +} + +function getIssueAssetExtension({ + filename, + mimeType, +}: { + filename?: string; + mimeType?: string; +}): string { + const lower = filename?.toLowerCase() ?? ""; + if (lower.endsWith(".png")) return "png"; + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "jpg"; + if (lower.endsWith(".gif")) return "gif"; + if (lower.endsWith(".webp")) return "webp"; + + if (mimeType === "image/jpeg") return "jpg"; + if (mimeType === "image/gif") return "gif"; + if (mimeType === "image/webp") return "webp"; + return "png"; +} + +async function ensureGitHubBranchExists({ + repoPath, + repositoryNameWithOwner, + branchName, + baseBranch, +}: { + repoPath: string; + repositoryNameWithOwner: string; + branchName: string; + baseBranch: string; +}) { + try { + await execWithShellEnv( + "gh", + ["api", `repos/${repositoryNameWithOwner}/git/ref/heads/${branchName}`], + { cwd: repoPath }, + ); + return; + } catch (error) { + const errorText = + error instanceof Error + ? [ + error.message, + "stderr" in error && typeof error.stderr === "string" + ? error.stderr + : "", + "stdout" in error && typeof error.stdout === "string" + ? error.stdout + : "", + ] + .join("\n") + .toLowerCase() + : String(error).toLowerCase(); + const isMissingRefError = + errorText.includes("404") || + errorText.includes("not found") || + errorText.includes("no ref found"); + + if (!isMissingRefError) { + console.warn("[git-status] GitHub branch probe failed", { + repoPath, + repositoryNameWithOwner, + branchName, + baseBranch, + error, + }); + throw error; + } + + console.warn("[git-status] GitHub branch not found, creating branch", { + repoPath, + repositoryNameWithOwner, + branchName, + baseBranch, + error, + }); + } + + const { stdout } = await execWithShellEnv( + "gh", + ["api", `repos/${repositoryNameWithOwner}/git/ref/heads/${baseBranch}`], + { cwd: repoPath }, + ); + const raw = JSON.parse(stdout) as { + object?: { sha?: string }; + }; + const sha = raw.object?.sha; + if (!sha) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Could not determine the base branch SHA for issue assets.", + }); + } + + await execWithShellEnv( + "gh", + [ + "api", + "--method", + "POST", + `repos/${repositoryNameWithOwner}/git/refs`, + "-f", + `ref=refs/heads/${branchName}`, + "-f", + `sha=${sha}`, + ], + { cwd: repoPath }, + ); +} + +function parseRunIdFromActionsUrl(detailsUrl?: string): string | null { + if (!detailsUrl) { + return null; + } + + try { + const url = new URL(detailsUrl); + const match = url.pathname.match(/\/actions\/runs\/(\d+)(?:\/|$)/); + return match?.[1] ?? null; + } catch { + return null; + } +} + +function isGitHubActionsUrl(url?: string): boolean { + return parseRunIdFromActionsUrl(url) !== null; +} + +function workflowSupportsDispatch({ + repoPath, + workflowPath, +}: { + repoPath: string; + workflowPath?: string; +}): boolean { + if (!workflowPath) { + return false; + } + + const absolutePath = path.join(repoPath, workflowPath); + if (!existsSync(absolutePath)) { + return false; + } + + try { + const content = readFileSync(absolutePath, "utf8"); + return ( + /^\s*workflow_dispatch\s*:/m.test(content) || + /^\s*on\s*:\s*workflow_dispatch\s*$/m.test(content) || + /^\s*on\s*:\s*\[[^\]]*\bworkflow_dispatch\b[^\]]*\]/m.test(content) + ); + } catch { + return false; + } +} + function resolveCommentsPullRequestTarget({ input, githubStatus, @@ -150,6 +377,459 @@ async function getFreshPullRequestForWorkspace(workspaceId: string): Promise<{ return { repoPath, worktree, pullRequest }; } +async function resolveRepositoryTargetForWorkspace( + workspaceId: string, +): Promise<{ + repoPath: string; + worktree: NonNullable> | null; + repositoryUrl: string; + repositoryNameWithOwner: string; + upstreamUrl: string; + upstreamNameWithOwner: string; + isFork: boolean; + branchExistsOnRemote: boolean; + currentBranch: string; + defaultBranch: string; +}> { + const { repoPath, worktree } = resolveRepoPathForWorkspace(workspaceId); + const [githubStatus, repoContext, currentBranch, defaultBranch] = + await Promise.all([ + fetchGitHubPRStatus(repoPath), + getRepoContext(repoPath), + getCurrentBranch(repoPath), + getDefaultBranch(repoPath), + ]); + + const repoUrl = githubStatus?.repoUrl ?? repoContext?.repoUrl; + const upstreamUrl = + githubStatus?.upstreamUrl ?? repoContext?.upstreamUrl ?? repoUrl; + const isFork = githubStatus?.isFork ?? repoContext?.isFork ?? false; + const repositoryUrl = repoUrl; + const repositoryNameWithOwner = repositoryUrl + ? extractNwoFromUrl(repositoryUrl) + : null; + const upstreamNameWithOwner = upstreamUrl + ? extractNwoFromUrl(upstreamUrl) + : null; + + if ( + !repoUrl || + !upstreamUrl || + !repositoryUrl || + !repositoryNameWithOwner || + !upstreamNameWithOwner + ) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Could not determine the GitHub repository for this workspace.", + }); + } + + if (!currentBranch) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Could not determine the current branch for this workspace.", + }); + } + + return { + repoPath, + worktree, + repositoryUrl, + repositoryNameWithOwner, + upstreamUrl, + upstreamNameWithOwner, + isFork, + branchExistsOnRemote: githubStatus?.branchExistsOnRemote ?? false, + currentBranch, + defaultBranch, + }; +} + +async function getGitHubRepositoryOverview(workspaceId: string) { + const { + repoPath, + repositoryNameWithOwner, + repositoryUrl, + upstreamUrl, + upstreamNameWithOwner, + isFork, + branchExistsOnRemote, + currentBranch, + defaultBranch, + } = await resolveRepositoryTargetForWorkspace(workspaceId); + + const [pullRequestsResult, workflowsResult, labelsResult, assigneesResult] = + await Promise.all([ + execWithShellEnv( + "gh", + [ + "pr", + "list", + "--repo", + repositoryNameWithOwner, + "--state", + "open", + "--limit", + "8", + "--json", + "number,title,url,state,isDraft,headRefName,updatedAt,author", + ], + { cwd: repoPath }, + ), + execWithShellEnv( + "gh", + [ + "api", + `repos/${repositoryNameWithOwner}/actions/workflows?per_page=100`, + ], + { cwd: repoPath }, + ), + execWithShellEnv( + "gh", + ["api", `repos/${repositoryNameWithOwner}/labels?per_page=100`], + { cwd: repoPath }, + ), + execWithShellEnv( + "gh", + ["api", `repos/${repositoryNameWithOwner}/assignees?per_page=100`], + { cwd: repoPath }, + ), + ]); + + const rawPullRequests = JSON.parse(pullRequestsResult.stdout) as unknown; + const pullRequests = z + .array(ghRepositoryPullRequestSchema) + .parse(rawPullRequests); + + const rawWorkflows = JSON.parse(workflowsResult.stdout) as unknown; + const workflows = + ghRepositoryWorkflowsResponseSchema.parse(rawWorkflows).workflows ?? []; + const rawLabels = JSON.parse(labelsResult.stdout) as unknown; + const labels = z.array(ghRepositoryLabelSchema).parse(rawLabels); + const rawAssignees = JSON.parse(assigneesResult.stdout) as unknown; + const assignees = z.array(ghRepositoryAssigneeSchema).parse(rawAssignees); + + return { + repositoryNameWithOwner, + repositoryUrl, + upstreamUrl, + upstreamNameWithOwner, + isFork, + branchExistsOnRemote, + currentBranch, + defaultBranch, + issueAssignees: assignees.map((assignee) => ({ + login: assignee.login, + avatarUrl: assignee.avatar_url ?? null, + })), + issueLabels: labels.map((label) => ({ + name: label.name, + color: label.color ?? "", + description: label.description ?? "", + })), + pullsUrl: `${repositoryUrl}/pulls`, + issuesUrl: `${repositoryUrl}/issues`, + actionsUrl: `${repositoryUrl}/actions`, + newIssueUrl: `${repositoryUrl}/issues/new`, + pullRequests: pullRequests.map((pullRequest) => ({ + number: pullRequest.number, + title: pullRequest.title, + url: pullRequest.url, + state: pullRequest.isDraft ? "draft" : pullRequest.state.toLowerCase(), + headRefName: pullRequest.headRefName ?? "", + updatedAt: pullRequest.updatedAt ?? null, + authorLogin: pullRequest.author?.login ?? null, + })), + workflows: workflows + .filter((workflow) => workflow.state !== "disabled_manually") + .filter((workflow) => + workflowSupportsDispatch({ + repoPath, + workflowPath: workflow.path, + }), + ) + .map((workflow) => ({ + id: workflow.id, + name: workflow.name, + path: workflow.path ?? "", + state: workflow.state ?? "unknown", + })), + }; +} + +async function createGitHubIssueForWorkspace({ + workspaceId, + title, + body, + assignees, + labels, +}: { + workspaceId: string; + title: string; + body?: string; + assignees?: string[]; + labels?: string[]; +}) { + const { repoPath, repositoryNameWithOwner } = + await resolveRepositoryTargetForWorkspace(workspaceId); + const args = [ + "issue", + "create", + "--repo", + repositoryNameWithOwner, + "--title", + title.trim(), + "--body", + body?.trim() || "", + ]; + const normalizedAssignees = normalizeIdentityList(assignees ?? []); + const normalizedLabels = normalizeIdentityList(labels ?? []); + if (normalizedAssignees.length > 0) { + args.push("--assignee", normalizedAssignees.join(",")); + } + if (normalizedLabels.length > 0) { + args.push("--label", normalizedLabels.join(",")); + } + const { stdout } = await execWithShellEnv("gh", args, { cwd: repoPath }); + + return { + url: stdout.trim(), + }; +} + +async function uploadIssueAssetForWorkspace({ + workspaceId, + filename, + contentBase64, + mimeType, +}: { + workspaceId: string; + filename: string; + contentBase64: string; + mimeType?: string; +}) { + const { repoPath, repositoryNameWithOwner, defaultBranch } = + await resolveRepositoryTargetForWorkspace(workspaceId); + const assetBranch = "superset-issue-assets"; + await ensureGitHubBranchExists({ + repoPath, + repositoryNameWithOwner, + branchName: assetBranch, + baseBranch: defaultBranch, + }); + + const now = new Date(); + const extension = getIssueAssetExtension({ filename, mimeType }); + const basename = + sanitizeIssueAssetBasename(filename.replace(/\.[^.]+$/, "")) || + "pasted-image"; + const timestamp = now.toISOString().replace(/[:.]/g, "-"); + const assetPath = [ + ".superset", + "issue-assets", + String(now.getUTCFullYear()), + String(now.getUTCMonth() + 1).padStart(2, "0"), + `${timestamp}-${basename}.${extension}`, + ].join("/"); + + await execWithShellEnv( + "gh", + [ + "api", + "--method", + "PUT", + `repos/${repositoryNameWithOwner}/contents/${assetPath}`, + "-f", + `message=Add issue asset ${assetPath}`, + "-f", + `content=${contentBase64}`, + "-f", + `branch=${assetBranch}`, + ], + { cwd: repoPath }, + ); + + const assetUrl = `https://github.com/${repositoryNameWithOwner}/raw/${assetBranch}/${assetPath}`; + + return { + name: `${basename}.${extension}`, + url: assetUrl, + markdown: `![${basename}](${assetUrl})`, + }; +} + +async function dispatchGitHubWorkflowForWorkspace({ + workspaceId, + workflowId, + ref, +}: { + workspaceId: string; + workflowId: number; + ref?: string; +}) { + const { repoPath, repositoryNameWithOwner, currentBranch, defaultBranch } = + await resolveRepositoryTargetForWorkspace(workspaceId); + const requestedRef = ref?.trim() || currentBranch || defaultBranch; + let targetRef = requestedRef; + if (requestedRef === currentBranch) { + const branchCheck = await branchExistsOnRemote( + repoPath, + currentBranch, + "origin", + ); + if (branchCheck.status !== "exists") { + targetRef = defaultBranch; + } + } + + await execWithShellEnv( + "gh", + [ + "api", + "--method", + "POST", + `repos/${repositoryNameWithOwner}/actions/workflows/${workflowId}/dispatches`, + "-f", + `ref=${targetRef}`, + ], + { cwd: repoPath }, + ); + + return { + success: true as const, + ref: targetRef, + dispatchedAt: new Date().toISOString(), + }; +} + +async function getGitHubWorkflowRunsForWorkspace({ + workspaceId, + workflowId, +}: { + workspaceId: string; + workflowId: number; +}) { + const { repoPath, repositoryNameWithOwner } = + await resolveRepositoryTargetForWorkspace(workspaceId); + const { stdout } = await execWithShellEnv( + "gh", + [ + "api", + `repos/${repositoryNameWithOwner}/actions/workflows/${workflowId}/runs?per_page=10&event=workflow_dispatch`, + ], + { cwd: repoPath }, + ); + + const rawRuns = JSON.parse(stdout) as unknown; + const runs = + ghRepositoryWorkflowRunsResponseSchema.parse(rawRuns).workflow_runs ?? []; + + return runs.map((run) => ({ + id: run.id, + name: run.name ?? "", + displayTitle: run.display_title ?? "", + url: run.html_url ?? "", + status: run.status ?? "unknown", + conclusion: run.conclusion ?? null, + event: run.event ?? null, + createdAt: run.created_at ?? null, + updatedAt: run.updated_at ?? null, + runStartedAt: run.run_started_at ?? null, + headBranch: run.head_branch ?? null, + headSha: run.head_sha ?? null, + runNumber: run.run_number ?? null, + workflowId: run.workflow_id ?? workflowId, + })); +} + +async function rerunPullRequestChecksForWorkspace({ + workspaceId, + mode, +}: { + workspaceId: string; + mode: "all" | "failed"; +}) { + const { repoPath, worktree, pullRequest } = + await getFreshPullRequestForWorkspace(workspaceId); + const checksToRerun = pullRequest.checks.filter((check) => { + if (!isGitHubActionsUrl(check.url)) { + return false; + } + + if (mode === "failed") { + return check.status === "failure"; + } + + return true; + }); + + if (checksToRerun.length === 0) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: + mode === "failed" + ? "No failed GitHub Actions jobs found for this pull request." + : "No GitHub Actions jobs found for this pull request.", + }); + } + + const runTargets = new Map(); + for (const check of checksToRerun) { + const runId = parseRunIdFromActionsUrl(check.url); + const repositoryNameWithOwner = check.url + ? extractNwoFromUrl(check.url) + : null; + if (!runId || !repositoryNameWithOwner) { + continue; + } + + runTargets.set( + `${repositoryNameWithOwner}:${runId}`, + `${repositoryNameWithOwner}:${runId}`, + ); + } + + if (runTargets.size === 0) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "No rerunnable GitHub Actions runs were found.", + }); + } + + for (const target of runTargets.values()) { + const [repositoryNameWithOwner, runId] = target.split(":"); + if (!repositoryNameWithOwner || !runId) { + continue; + } + + await execWithShellEnv( + "gh", + [ + "api", + "--method", + "POST", + `repos/${repositoryNameWithOwner}/actions/runs/${runId}/${mode === "failed" ? "rerun-failed-jobs" : "rerun"}`, + ], + { cwd: repoPath }, + ); + } + + clearGitHubCachesForWorktree(repoPath); + if (worktree) { + localDb + .update(worktrees) + .set({ githubStatus: null }) + .where(eq(worktrees.id, worktree.id)) + .run(); + } + + return { + success: true as const, + rerunCount: runTargets.size, + }; +} + function resolvePullRequestTarget({ workspaceId, pullRequestNumber, @@ -291,7 +971,7 @@ async function getPullRequestIdentityCandidates({ workspaceId: string; kind: "reviewer" | "assignee"; pullRequestUrl?: string; -}): Promise { +}): Promise> { const { repoPath, repoNameWithOwner } = resolvePullRequestRepoTarget({ workspaceId, pullRequestUrl, @@ -309,6 +989,7 @@ async function getPullRequestIdentityCandidates({ users: ${fieldName}(first: 100, after: $after) { nodes { login + avatarUrl } pageInfo { hasNextPage @@ -318,7 +999,7 @@ async function getPullRequestIdentityCandidates({ } }`; - const logins = new Set(); + const usersByLogin = new Map(); let afterCursor: string | null = null; while (true) { @@ -354,7 +1035,7 @@ async function getPullRequestIdentityCandidates({ for (const user of users.nodes ?? []) { if (user?.login) { - logins.add(user.login); + usersByLogin.set(user.login, user.avatarUrl ?? null); } } @@ -365,7 +1046,10 @@ async function getPullRequestIdentityCandidates({ afterCursor = users.pageInfo.endCursor; } - return [...logins]; + return [...usersByLogin.entries()].map(([login, avatarUrl]) => ({ + login, + avatarUrl, + })); } export const createGitStatusProcedures = () => { @@ -550,6 +1234,77 @@ export const createGitStatusProcedures = () => { return getPullRequestIdentityCandidates(input); }), + getGitHubRepositoryOverview: publicProcedure + .input( + z.object({ + workspaceId: z.string(), + }), + ) + .query(async ({ input }) => { + return getGitHubRepositoryOverview(input.workspaceId); + }), + + createGitHubIssue: publicProcedure + .input( + z.object({ + workspaceId: z.string(), + title: z.string().trim().min(1), + body: z.string().optional(), + assignees: z.array(z.string()).optional(), + labels: z.array(z.string()).optional(), + }), + ) + .mutation(async ({ input }) => { + return createGitHubIssueForWorkspace(input); + }), + + uploadGitHubIssueAsset: publicProcedure + .input( + z.object({ + workspaceId: z.string(), + filename: z.string().trim().min(1), + contentBase64: z.string().trim().min(1), + mimeType: z.string().optional(), + }), + ) + .mutation(async ({ input }) => { + return uploadIssueAssetForWorkspace(input); + }), + + dispatchGitHubWorkflow: publicProcedure + .input( + z.object({ + workspaceId: z.string(), + workflowId: z.number().int().positive(), + ref: z.string().optional(), + }), + ) + .mutation(async ({ input }) => { + return dispatchGitHubWorkflowForWorkspace(input); + }), + + getGitHubWorkflowRuns: publicProcedure + .input( + z.object({ + workspaceId: z.string(), + workflowId: z.number().int().positive(), + }), + ) + .query(async ({ input }) => { + return getGitHubWorkflowRunsForWorkspace(input); + }), + + rerunPullRequestChecks: publicProcedure + .input( + z.object({ + workspaceId: z.string(), + mode: z.enum(["all", "failed"]), + }), + ) + .mutation(async ({ input }) => { + return rerunPullRequestChecksForWorkspace(input); + }), + setPullRequestDraftState: publicProcedure .input( z.object({ diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/types.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/types.ts index b72420472de..391a16115da 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/types.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/types.ts @@ -36,6 +36,11 @@ export const GHUserSchema = z.object({ login: z.string().optional(), }); +export const GHIdentityCandidateUserSchema = z.object({ + login: z.string().optional(), + avatarUrl: z.string().optional(), +}); + export const GHCommentAuthorSchema = z.object({ login: z.string().optional(), avatar_url: z.string().optional(), @@ -83,7 +88,7 @@ export const GHPageInfoSchema = z.object({ }); export const GHUsersConnectionSchema = z.object({ - nodes: z.array(GHUserSchema.nullable()).optional(), + nodes: z.array(GHIdentityCandidateUserSchema.nullable()).optional(), pageInfo: GHPageInfoSchema, }); diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-common.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-common.ts index 55a2f14ab8a..90af90ebb64 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-common.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-common.ts @@ -120,6 +120,8 @@ if [ -z "$REAL_BIN" ]; then exit 127 fi +export SUPERSET_WRAPPER_PID="$$" + ${execLine} `; } diff --git a/apps/desktop/src/main/lib/agent-setup/templates/copilot-hook.template.sh b/apps/desktop/src/main/lib/agent-setup/templates/copilot-hook.template.sh index cdfb1581084..310257a8ec8 100644 --- a/apps/desktop/src/main/lib/agent-setup/templates/copilot-hook.template.sh +++ b/apps/desktop/src/main/lib/agent-setup/templates/copilot-hook.template.sh @@ -29,6 +29,40 @@ printf '{}\n' [ -z "$SUPERSET_TAB_ID" ] && exit 0 +if [ -n "$SUPERSET_WRAPPER_PID" ] && [ "$SUPERSET_PREVENT_AGENT_SLEEP" = "1" ] && [ "$(uname -s 2>/dev/null)" = "Darwin" ] && command -v caffeinate >/dev/null 2>&1; then + _superset_sleep_dir="${TMPDIR:-/tmp}/superset-sleep-inhibitors" + mkdir -p "$_superset_sleep_dir" >/dev/null 2>&1 || true + _superset_pid_file="$_superset_sleep_dir/${SUPERSET_WRAPPER_PID}.pid" + case "$EVENT_TYPE" in + Start|PermissionRequest) + if [ -f "$_superset_pid_file" ]; then + _superset_caffeinate_pid=$(cat "$_superset_pid_file" 2>/dev/null) + if [ -n "$_superset_caffeinate_pid" ] && kill -0 "$_superset_caffeinate_pid" 2>/dev/null; then + : + else + rm -f "$_superset_pid_file" >/dev/null 2>&1 || true + if kill -0 "$SUPERSET_WRAPPER_PID" 2>/dev/null; then + caffeinate -i -w "$SUPERSET_WRAPPER_PID" >/dev/null 2>&1 & + echo "$!" > "$_superset_pid_file" + fi + fi + elif kill -0 "$SUPERSET_WRAPPER_PID" 2>/dev/null; then + caffeinate -i -w "$SUPERSET_WRAPPER_PID" >/dev/null 2>&1 & + echo "$!" > "$_superset_pid_file" + fi + ;; + Stop) + if [ -f "$_superset_pid_file" ]; then + _superset_caffeinate_pid=$(cat "$_superset_pid_file" 2>/dev/null) + if [ -n "$_superset_caffeinate_pid" ] && kill -0 "$_superset_caffeinate_pid" 2>/dev/null; then + kill "$_superset_caffeinate_pid" >/dev/null 2>&1 || true + fi + rm -f "$_superset_pid_file" >/dev/null 2>&1 || true + fi + ;; + esac +fi + curl -sG "http://127.0.0.1:${SUPERSET_PORT:-{{DEFAULT_PORT}}}/hook/complete" \ --connect-timeout 1 --max-time 2 \ --data-urlencode "paneId=$SUPERSET_PANE_ID" \ diff --git a/apps/desktop/src/main/lib/agent-setup/templates/cursor-hook.template.sh b/apps/desktop/src/main/lib/agent-setup/templates/cursor-hook.template.sh index f2e2483ffa9..3e435ab376e 100644 --- a/apps/desktop/src/main/lib/agent-setup/templates/cursor-hook.template.sh +++ b/apps/desktop/src/main/lib/agent-setup/templates/cursor-hook.template.sh @@ -27,6 +27,40 @@ fi # cursor-agent runs inside a Superset terminal, so env vars are inherited directly [ -z "$SUPERSET_TAB_ID" ] && exit 0 +if [ -n "$SUPERSET_WRAPPER_PID" ] && [ "$SUPERSET_PREVENT_AGENT_SLEEP" = "1" ] && [ "$(uname -s 2>/dev/null)" = "Darwin" ] && command -v caffeinate >/dev/null 2>&1; then + _superset_sleep_dir="${TMPDIR:-/tmp}/superset-sleep-inhibitors" + mkdir -p "$_superset_sleep_dir" >/dev/null 2>&1 || true + _superset_pid_file="$_superset_sleep_dir/${SUPERSET_WRAPPER_PID}.pid" + case "$EVENT_TYPE" in + Start|PermissionRequest) + if [ -f "$_superset_pid_file" ]; then + _superset_caffeinate_pid=$(cat "$_superset_pid_file" 2>/dev/null) + if [ -n "$_superset_caffeinate_pid" ] && kill -0 "$_superset_caffeinate_pid" 2>/dev/null; then + : + else + rm -f "$_superset_pid_file" >/dev/null 2>&1 || true + if kill -0 "$SUPERSET_WRAPPER_PID" 2>/dev/null; then + caffeinate -i -w "$SUPERSET_WRAPPER_PID" >/dev/null 2>&1 & + echo "$!" > "$_superset_pid_file" + fi + fi + elif kill -0 "$SUPERSET_WRAPPER_PID" 2>/dev/null; then + caffeinate -i -w "$SUPERSET_WRAPPER_PID" >/dev/null 2>&1 & + echo "$!" > "$_superset_pid_file" + fi + ;; + Stop) + if [ -f "$_superset_pid_file" ]; then + _superset_caffeinate_pid=$(cat "$_superset_pid_file" 2>/dev/null) + if [ -n "$_superset_caffeinate_pid" ] && kill -0 "$_superset_caffeinate_pid" 2>/dev/null; then + kill "$_superset_caffeinate_pid" >/dev/null 2>&1 || true + fi + rm -f "$_superset_pid_file" >/dev/null 2>&1 || true + fi + ;; + esac +fi + curl -sG "http://127.0.0.1:${SUPERSET_PORT:-{{DEFAULT_PORT}}}/hook/complete" \ --connect-timeout 1 --max-time 2 \ --data-urlencode "paneId=$SUPERSET_PANE_ID" \ diff --git a/apps/desktop/src/main/lib/agent-setup/templates/gemini-hook.template.sh b/apps/desktop/src/main/lib/agent-setup/templates/gemini-hook.template.sh index a54e780c99a..950d372d483 100644 --- a/apps/desktop/src/main/lib/agent-setup/templates/gemini-hook.template.sh +++ b/apps/desktop/src/main/lib/agent-setup/templates/gemini-hook.template.sh @@ -28,6 +28,40 @@ printf '{}\n' # Skip notification if not inside a Superset terminal [ -z "$SUPERSET_TAB_ID" ] && exit 0 +if [ -n "$SUPERSET_WRAPPER_PID" ] && [ "$SUPERSET_PREVENT_AGENT_SLEEP" = "1" ] && [ "$(uname -s 2>/dev/null)" = "Darwin" ] && command -v caffeinate >/dev/null 2>&1; then + _superset_sleep_dir="${TMPDIR:-/tmp}/superset-sleep-inhibitors" + mkdir -p "$_superset_sleep_dir" >/dev/null 2>&1 || true + _superset_pid_file="$_superset_sleep_dir/${SUPERSET_WRAPPER_PID}.pid" + case "$EVENT_TYPE" in + Start|PermissionRequest) + if [ -f "$_superset_pid_file" ]; then + _superset_caffeinate_pid=$(cat "$_superset_pid_file" 2>/dev/null) + if [ -n "$_superset_caffeinate_pid" ] && kill -0 "$_superset_caffeinate_pid" 2>/dev/null; then + : + else + rm -f "$_superset_pid_file" >/dev/null 2>&1 || true + if kill -0 "$SUPERSET_WRAPPER_PID" 2>/dev/null; then + caffeinate -i -w "$SUPERSET_WRAPPER_PID" >/dev/null 2>&1 & + echo "$!" > "$_superset_pid_file" + fi + fi + elif kill -0 "$SUPERSET_WRAPPER_PID" 2>/dev/null; then + caffeinate -i -w "$SUPERSET_WRAPPER_PID" >/dev/null 2>&1 & + echo "$!" > "$_superset_pid_file" + fi + ;; + Stop) + if [ -f "$_superset_pid_file" ]; then + _superset_caffeinate_pid=$(cat "$_superset_pid_file" 2>/dev/null) + if [ -n "$_superset_caffeinate_pid" ] && kill -0 "$_superset_caffeinate_pid" 2>/dev/null; then + kill "$_superset_caffeinate_pid" >/dev/null 2>&1 || true + fi + rm -f "$_superset_pid_file" >/dev/null 2>&1 || true + fi + ;; + esac +fi + curl -sG "http://127.0.0.1:${SUPERSET_PORT:-{{DEFAULT_PORT}}}/hook/complete" \ --connect-timeout 1 --max-time 2 \ --data-urlencode "paneId=$SUPERSET_PANE_ID" \ diff --git a/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh b/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh index 925702abf4b..7aebb075293 100644 --- a/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh +++ b/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh @@ -53,6 +53,43 @@ fi # This prevents parse failures from causing false completion notifications [ -z "$EVENT_TYPE" ] && exit 0 +_superset_manage_sleep_inhibitor() { + [ -n "$SUPERSET_WRAPPER_PID" ] || return 0 + [ "$SUPERSET_PREVENT_AGENT_SLEEP" = "1" ] || return 0 + [ "$(uname -s 2>/dev/null)" = "Darwin" ] || return 0 + command -v caffeinate >/dev/null 2>&1 || return 0 + + _superset_sleep_dir="${TMPDIR:-/tmp}/superset-sleep-inhibitors" + mkdir -p "$_superset_sleep_dir" >/dev/null 2>&1 || return 0 + _superset_pid_file="$_superset_sleep_dir/${SUPERSET_WRAPPER_PID}.pid" + + case "$EVENT_TYPE" in + Start|PermissionRequest) + if [ -f "$_superset_pid_file" ]; then + _superset_caffeinate_pid=$(cat "$_superset_pid_file" 2>/dev/null) + if [ -n "$_superset_caffeinate_pid" ] && kill -0 "$_superset_caffeinate_pid" 2>/dev/null; then + return 0 + fi + rm -f "$_superset_pid_file" >/dev/null 2>&1 || true + fi + kill -0 "$SUPERSET_WRAPPER_PID" 2>/dev/null || return 0 + caffeinate -i -w "$SUPERSET_WRAPPER_PID" >/dev/null 2>&1 & + echo "$!" > "$_superset_pid_file" + ;; + Stop) + if [ -f "$_superset_pid_file" ]; then + _superset_caffeinate_pid=$(cat "$_superset_pid_file" 2>/dev/null) + if [ -n "$_superset_caffeinate_pid" ] && kill -0 "$_superset_caffeinate_pid" 2>/dev/null; then + kill "$_superset_caffeinate_pid" >/dev/null 2>&1 || true + fi + rm -f "$_superset_pid_file" >/dev/null 2>&1 || true + fi + ;; + esac +} + +_superset_manage_sleep_inhibitor + DEBUG_HOOKS_ENABLED="0" if [ -n "$SUPERSET_DEBUG_HOOKS" ]; then case "$SUPERSET_DEBUG_HOOKS" in @@ -68,7 +105,7 @@ elif [ "$SUPERSET_ENV" = "development" ] || [ "$NODE_ENV" = "development" ]; the fi if [ "$DEBUG_HOOKS_ENABLED" = "1" ]; then - echo "[notify-hook] event=$EVENT_TYPE sessionId=$SESSION_ID hookSessionId=$HOOK_SESSION_ID resourceId=$RESOURCE_ID paneId=$SUPERSET_PANE_ID tabId=$SUPERSET_TAB_ID workspaceId=$SUPERSET_WORKSPACE_ID" >&2 + echo "[notify-hook] event=$EVENT_TYPE sessionId=$SESSION_ID hookSessionId=$HOOK_SESSION_ID resourceId=$RESOURCE_ID paneId=$SUPERSET_PANE_ID tabId=$SUPERSET_TAB_ID workspaceId=$SUPERSET_WORKSPACE_ID wrapperPid=$SUPERSET_WRAPPER_PID" >&2 fi # Timeouts prevent blocking agent completion if notification server is unresponsive diff --git a/apps/desktop/src/main/lib/language-services/diagnostics-store.ts b/apps/desktop/src/main/lib/language-services/diagnostics-store.ts new file mode 100644 index 00000000000..d728ce240ae --- /dev/null +++ b/apps/desktop/src/main/lib/language-services/diagnostics-store.ts @@ -0,0 +1,184 @@ +import { EventEmitter } from "node:events"; +import type { + LanguageServiceDiagnostic, + LanguageServiceWorkspaceSnapshot, +} from "./types"; + +const MAX_PROBLEMS = 500; + +function diagnosticSortValue(severity: string): number { + switch (severity) { + case "error": + return 0; + case "warning": + return 1; + case "info": + return 2; + default: + return 3; + } +} + +type WorkspaceDiagnostics = Map; + +export class LanguageDiagnosticsStore { + private readonly workspaces = new Map(); + + private readonly versions = new Map(); + + private readonly emitter = new EventEmitter(); + + setFileDiagnostics( + workspaceId: string, + fileKey: string, + diagnostics: LanguageServiceDiagnostic[], + ): void { + const workspaceDiagnostics = + this.workspaces.get(workspaceId) ?? + new Map(); + workspaceDiagnostics.set(fileKey, diagnostics); + this.workspaces.set(workspaceId, workspaceDiagnostics); + this.bump(workspaceId); + } + + clearFileDiagnostics(workspaceId: string, fileKey: string): void { + const workspaceDiagnostics = this.workspaces.get(workspaceId); + if (!workspaceDiagnostics) { + return; + } + + if (!workspaceDiagnostics.delete(fileKey)) { + return; + } + + if (workspaceDiagnostics.size === 0) { + this.workspaces.delete(workspaceId); + } + + this.bump(workspaceId); + } + + clearWorkspace(workspaceId: string): void { + if (!this.workspaces.delete(workspaceId)) { + return; + } + + this.bump(workspaceId); + } + + clearProviderDiagnostics(providerId: string, workspaceId?: string): void { + const fileKeyPrefix = `${providerId}::`; + const targetWorkspaceIds = workspaceId + ? [workspaceId] + : Array.from(this.workspaces.keys()); + + for (const targetWorkspaceId of targetWorkspaceIds) { + const workspaceDiagnostics = this.workspaces.get(targetWorkspaceId); + if (!workspaceDiagnostics) { + continue; + } + + let changed = false; + for (const fileKey of Array.from(workspaceDiagnostics.keys())) { + if (!fileKey.startsWith(fileKeyPrefix)) { + continue; + } + + workspaceDiagnostics.delete(fileKey); + changed = true; + } + + if (!changed) { + continue; + } + + if (workspaceDiagnostics.size === 0) { + this.workspaces.delete(targetWorkspaceId); + } + + this.bump(targetWorkspaceId); + } + } + + getVersion(workspaceId: string): number { + return this.versions.get(workspaceId) ?? 0; + } + + subscribe( + workspaceId: string, + listener: (payload: { version: number }) => void, + ) { + const eventName = this.eventName(workspaceId); + this.emitter.on(eventName, listener); + return () => { + this.emitter.off(eventName, listener); + }; + } + + createSnapshot(args: { + workspaceId: string; + workspacePath: string; + providers: LanguageServiceWorkspaceSnapshot["providers"]; + }): LanguageServiceWorkspaceSnapshot { + const flattened = Array.from( + this.workspaces.get(args.workspaceId)?.values() ?? [], + ) + .flat() + .sort((left, right) => { + const severityDelta = + diagnosticSortValue(left.severity) - + diagnosticSortValue(right.severity); + if (severityDelta !== 0) { + return severityDelta; + } + + const pathDelta = (left.relativePath ?? "").localeCompare( + right.relativePath ?? "", + ); + if (pathDelta !== 0) { + return pathDelta; + } + + const lineDelta = (left.line ?? 0) - (right.line ?? 0); + if (lineDelta !== 0) { + return lineDelta; + } + + return (left.column ?? 0) - (right.column ?? 0); + }); + + const problems = flattened.slice(0, MAX_PROBLEMS); + return { + status: "ready", + workspaceId: args.workspaceId, + workspacePath: args.workspacePath, + providers: args.providers, + problems, + totalCount: flattened.length, + truncated: flattened.length > problems.length, + summary: { + errorCount: flattened.filter((problem) => problem.severity === "error") + .length, + warningCount: flattened.filter( + (problem) => problem.severity === "warning", + ).length, + infoCount: flattened.filter((problem) => problem.severity === "info") + .length, + hintCount: flattened.filter((problem) => problem.severity === "hint") + .length, + }, + }; + } + + private bump(workspaceId: string): void { + const version = (this.versions.get(workspaceId) ?? 0) + 1; + this.versions.set(workspaceId, version); + this.emitter.emit(this.eventName(workspaceId), { version }); + } + + private eventName(workspaceId: string): string { + return `workspace:${workspaceId}`; + } +} + +export const languageDiagnosticsStore = new LanguageDiagnosticsStore(); diff --git a/apps/desktop/src/main/lib/language-services/lsp/StdioJsonRpcClient.ts b/apps/desktop/src/main/lib/language-services/lsp/StdioJsonRpcClient.ts new file mode 100644 index 00000000000..61fba479bc0 --- /dev/null +++ b/apps/desktop/src/main/lib/language-services/lsp/StdioJsonRpcClient.ts @@ -0,0 +1,306 @@ +import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; + +type JsonRpcId = number | string | null; + +type JsonRpcRequestMessage = { + jsonrpc: "2.0"; + id: JsonRpcId; + method: string; + params?: unknown; +}; + +type JsonRpcNotificationMessage = { + jsonrpc: "2.0"; + method: string; + params?: unknown; +}; + +type JsonRpcResponseMessage = { + jsonrpc: "2.0"; + id: JsonRpcId; + result?: unknown; + error?: { + code: number; + message: string; + data?: unknown; + }; +}; + +type JsonRpcMessage = + | JsonRpcRequestMessage + | JsonRpcNotificationMessage + | JsonRpcResponseMessage; + +type PendingRequest = { + resolve: (value: unknown) => void; + reject: (error: Error) => void; +}; + +type StdioJsonRpcClientOptions = { + name: string; + command: string; + args?: string[]; + cwd?: string; + env?: NodeJS.ProcessEnv; + shell?: boolean; + onNotification?: (message: JsonRpcNotificationMessage) => void; + onRequest?: (message: JsonRpcRequestMessage) => Promise | unknown; + onExit?: (payload: { + code: number | null; + signal: NodeJS.Signals | null; + }) => void; + onStderr?: (chunk: string) => void; +}; + +function isJsonRpcResponseMessage( + message: JsonRpcMessage, +): message is JsonRpcResponseMessage { + return "id" in message && !("method" in message); +} + +function isJsonRpcRequestMessage( + message: JsonRpcMessage, +): message is JsonRpcRequestMessage { + return "id" in message && "method" in message; +} + +function consumeMessage( + buffer: Buffer, +): { body: string; rest: Buffer } | null { + const separatorIndex = buffer.indexOf("\r\n\r\n"); + if (separatorIndex === -1) { + return null; + } + + const header = buffer.subarray(0, separatorIndex).toString("utf8"); + const contentLengthMatch = /Content-Length:\s*(\d+)/i.exec(header); + if (!contentLengthMatch) { + return null; + } + + const contentLength = Number(contentLengthMatch[1]); + const bodyStart = separatorIndex + 4; + const bodyEnd = bodyStart + contentLength; + if (buffer.length < bodyEnd) { + return null; + } + + return { + body: buffer.subarray(bodyStart, bodyEnd).toString("utf8"), + rest: buffer.subarray(bodyEnd), + }; +} + +export class StdioJsonRpcClient { + private process: ChildProcessWithoutNullStreams | null = null; + + private nextId = 0; + + private buffer: Buffer = Buffer.alloc(0); + + private readonly pendingRequests = new Map(); + + constructor(private readonly options: StdioJsonRpcClientOptions) {} + + async start(): Promise { + if (this.process) { + return; + } + + const child = spawn(this.options.command, this.options.args ?? [], { + cwd: this.options.cwd, + env: this.options.env, + shell: this.options.shell, + stdio: ["pipe", "pipe", "pipe"], + }); + + this.process = child; + child.stdout.on("data", (chunk: Buffer) => { + this.handleStdout(chunk); + }); + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => { + this.options.onStderr?.(chunk); + }); + child.on("exit", (code, signal) => { + this.process = null; + for (const pendingRequest of this.pendingRequests.values()) { + pendingRequest.reject( + new Error( + `${this.options.name} exited (${code ?? "null"}${signal ? `, ${signal}` : ""})`, + ), + ); + } + this.pendingRequests.clear(); + this.options.onExit?.({ code, signal }); + }); + child.on("error", (error) => { + this.process = null; + for (const pendingRequest of this.pendingRequests.values()) { + pendingRequest.reject(error); + } + this.pendingRequests.clear(); + }); + } + + async request(method: string, params?: unknown): Promise { + const id = ++this.nextId; + return await new Promise((resolve, reject) => { + this.pendingRequests.set(id, { resolve, reject }); + void this.writeMessage({ + jsonrpc: "2.0", + id, + method, + params, + }).catch((error) => { + this.pendingRequests.delete(id); + reject(error); + }); + }); + } + + async notify(method: string, params?: unknown): Promise { + await this.writeMessage({ + jsonrpc: "2.0", + method, + params, + }); + } + + async stop(): Promise { + if (!this.process) { + return; + } + + const child = this.process; + this.process = null; + child.removeAllListeners(); + if (!child.killed) { + child.kill(); + } + + for (const pendingRequest of this.pendingRequests.values()) { + pendingRequest.reject(new Error(`${this.options.name} stopped`)); + } + this.pendingRequests.clear(); + } + + private handleStdout(chunk: Buffer): void { + this.buffer = Buffer.concat([this.buffer, chunk]); + while (true) { + const message = consumeMessage(this.buffer); + if (!message) { + return; + } + + this.buffer = message.rest; + if (!message.body.trim()) { + continue; + } + + try { + const parsed = JSON.parse(message.body) as JsonRpcMessage; + this.handleMessage(parsed); + } catch (error) { + console.error( + "[language-services/lsp] Failed to parse JSON-RPC payload", + { + name: this.options.name, + error, + body: message.body, + }, + ); + } + } + } + + private handleMessage(message: JsonRpcMessage): void { + if (isJsonRpcResponseMessage(message)) { + const requestId = Number(message.id); + const pendingRequest = Number.isNaN(requestId) + ? null + : this.pendingRequests.get(requestId); + if (!pendingRequest) { + return; + } + + this.pendingRequests.delete(requestId); + if (message.error) { + pendingRequest.reject(new Error(message.error.message)); + return; + } + + pendingRequest.resolve(message.result); + return; + } + + if (isJsonRpcRequestMessage(message)) { + void this.handleServerRequest(message); + return; + } + + this.options.onNotification?.(message); + } + + private async handleServerRequest( + message: JsonRpcRequestMessage, + ): Promise { + try { + const result = + (await this.options.onRequest?.(message)) ?? + this.defaultRequestResult(message.method); + await this.writeMessage({ + jsonrpc: "2.0", + id: message.id, + result: result ?? null, + }); + } catch (error) { + await this.writeMessage({ + jsonrpc: "2.0", + id: message.id, + error: { + code: -32603, + message: error instanceof Error ? error.message : String(error), + }, + }); + } + } + + private defaultRequestResult(method: string): unknown { + switch (method) { + case "client/registerCapability": + case "client/unregisterCapability": + case "window/workDoneProgress/create": + return null; + case "workspace/configuration": + return []; + default: + throw new Error(`Unhandled JSON-RPC request: ${method}`); + } + } + + private async writeMessage(message: JsonRpcMessage): Promise { + const child = this.process; + if (!child) { + throw new Error(`${this.options.name} is not running`); + } + + const payload = Buffer.from(JSON.stringify(message), "utf8"); + const header = Buffer.from( + `Content-Length: ${payload.byteLength}\r\n\r\n`, + "utf8", + ); + const combined = Buffer.concat([header, payload]); + + await new Promise((resolve, reject) => { + child.stdin.write(combined, (error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); + } +} diff --git a/apps/desktop/src/main/lib/language-services/manager.ts b/apps/desktop/src/main/lib/language-services/manager.ts new file mode 100644 index 00000000000..1b0491f8db6 --- /dev/null +++ b/apps/desktop/src/main/lib/language-services/manager.ts @@ -0,0 +1,183 @@ +import { languageDiagnosticsStore } from "./diagnostics-store"; +import { DartLanguageProvider } from "./providers/dart/DartLanguageProvider"; +import { JsonLanguageProvider } from "./providers/json/JsonLanguageProvider"; +import { TomlLanguageProvider } from "./providers/toml/TomlLanguageProvider"; +import { TypeScriptLanguageProvider } from "./providers/typescript/TypeScriptLanguageProvider"; +import type { + LanguageServiceDocument, + LanguageServiceProvider, + LanguageServiceProviderDescriptor, + LanguageServiceWorkspaceSnapshot, +} from "./types"; + +export class LanguageServiceManager { + private readonly providers: LanguageServiceProvider[] = [ + new TypeScriptLanguageProvider(), + new JsonLanguageProvider(), + new TomlLanguageProvider(), + new DartLanguageProvider(), + ]; + + private readonly enabledProviders = new Map( + this.providers.map((provider) => [provider.id, true] as const), + ); + + private readonly knownWorkspaces = new Map(); + + async syncDocument(document: LanguageServiceDocument): Promise { + this.rememberWorkspace(document.workspaceId, document.workspacePath); + const provider = this.resolveProvider(document.languageId); + if (!provider || !this.isProviderEnabled(provider.id)) { + return; + } + + await provider.changeDocument(document); + } + + async openDocument(document: LanguageServiceDocument): Promise { + this.rememberWorkspace(document.workspaceId, document.workspacePath); + const provider = this.resolveProvider(document.languageId); + if (!provider || !this.isProviderEnabled(provider.id)) { + return; + } + + await provider.openDocument(document); + } + + async closeDocument(args: { + workspaceId: string; + workspacePath: string; + absolutePath: string; + languageId: string; + }): Promise { + const provider = this.resolveProvider(args.languageId); + if (!provider) { + return; + } + + await provider.closeDocument(args); + } + + async refreshWorkspace(args: { + workspaceId: string; + workspacePath: string; + }): Promise { + this.rememberWorkspace(args.workspaceId, args.workspacePath); + await Promise.all( + this.providers + .filter((provider) => this.isProviderEnabled(provider.id)) + .map((provider) => provider.refreshWorkspace(args)), + ); + } + + async disposeWorkspace(args: { + workspaceId: string; + workspacePath: string; + }): Promise { + this.knownWorkspaces.delete(args.workspaceId); + await Promise.all( + this.providers.map((provider) => provider.disposeWorkspace(args)), + ); + languageDiagnosticsStore.clearWorkspace(args.workspaceId); + } + + getWorkspaceSnapshot(args: { + workspaceId: string; + workspacePath: string; + }): LanguageServiceWorkspaceSnapshot { + this.rememberWorkspace(args.workspaceId, args.workspacePath); + return languageDiagnosticsStore.createSnapshot({ + workspaceId: args.workspaceId, + workspacePath: args.workspacePath, + providers: this.providers.map((provider) => + provider.getWorkspaceSummary({ + workspaceId: args.workspaceId, + workspacePath: args.workspacePath, + enabled: this.isProviderEnabled(provider.id), + }), + ), + }); + } + + getProviders(): LanguageServiceProviderDescriptor[] { + return this.providers.map((provider) => ({ + providerId: provider.id, + label: provider.label, + description: provider.description, + languageIds: provider.languageIds, + enabled: this.isProviderEnabled(provider.id), + })); + } + + async setProviderEnabled( + providerId: string, + enabled: boolean, + ): Promise { + const provider = this.providers.find( + (candidate) => candidate.id === providerId, + ); + if (!provider) { + return null; + } + + const previous = this.isProviderEnabled(providerId); + if (previous === enabled) { + return { + providerId: provider.id, + label: provider.label, + description: provider.description, + languageIds: provider.languageIds, + enabled, + }; + } + + this.enabledProviders.set(providerId, enabled); + + if (!enabled) { + await Promise.all( + Array.from(this.knownWorkspaces.entries()).map( + async ([workspaceId, workspacePath]) => { + await provider.disposeWorkspace({ + workspaceId, + workspacePath, + }); + }, + ), + ); + languageDiagnosticsStore.clearProviderDiagnostics(providerId); + } + + return { + providerId: provider.id, + label: provider.label, + description: provider.description, + languageIds: provider.languageIds, + enabled, + }; + } + + subscribeToWorkspace( + workspaceId: string, + listener: (payload: { version: number }) => void, + ) { + return languageDiagnosticsStore.subscribe(workspaceId, listener); + } + + private isProviderEnabled(providerId: string): boolean { + return this.enabledProviders.get(providerId) ?? false; + } + + private rememberWorkspace(workspaceId: string, workspacePath: string): void { + this.knownWorkspaces.set(workspaceId, workspacePath); + } + + private resolveProvider(languageId: string): LanguageServiceProvider | null { + return ( + this.providers.find((provider) => + provider.supportsLanguage(languageId), + ) ?? null + ); + } +} + +export const languageServiceManager = new LanguageServiceManager(); diff --git a/apps/desktop/src/main/lib/language-services/providers/dart/DartLanguageProvider.ts b/apps/desktop/src/main/lib/language-services/providers/dart/DartLanguageProvider.ts new file mode 100644 index 00000000000..011f4ae2a8a --- /dev/null +++ b/apps/desktop/src/main/lib/language-services/providers/dart/DartLanguageProvider.ts @@ -0,0 +1,572 @@ +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { languageDiagnosticsStore } from "../../diagnostics-store"; +import { StdioJsonRpcClient } from "../../lsp/StdioJsonRpcClient"; +import type { + LanguageServiceDiagnostic, + LanguageServiceDocument, + LanguageServiceProvider, + LanguageServiceProviderSummary, + LanguageServiceRelatedInformation, +} from "../../types"; +import { + absolutePathToFileUri, + fileUriToAbsolutePath, + lspSeverityToLanguageServiceSeverity, + offsetToLspPosition, + toRelativeWorkspacePath, +} from "../../utils"; + +type OpenDocumentEntry = { + languageId: string; + version: number; + content: string; + uri: string; +}; + +type DartDiagnostic = { + range: { + start: { line: number; character: number }; + end: { line: number; character: number }; + }; + severity?: number; + code?: string | number; + source?: string; + message: string; + relatedInformation?: Array<{ + location: { + uri: string; + range: { + start: { line: number; character: number }; + end: { line: number; character: number }; + }; + }; + message: string; + }>; +}; + +type WorkspaceSession = { + workspaceId: string; + workspacePath: string; + dartCommand: string; + client: StdioJsonRpcClient; + openDocuments: Map; + lastError: string | null; + textDocumentSyncMode: "full" | "incremental"; +}; + +type ResolvedDartCommand = { + command: string; + shell: boolean; +}; + +function canExecute(command: string, shell: boolean): boolean { + const probe = spawnSync(command, ["--version"], { + stdio: "ignore", + shell, + }); + return probe.status === 0; +} + +function getEnvCandidateCommands(): string[] { + const executableName = process.platform === "win32" ? "dart.exe" : "dart"; + const wrapperName = process.platform === "win32" ? "dart.bat" : "dart"; + return [ + process.env.DART_SDK + ? path.join(process.env.DART_SDK, "bin", executableName) + : null, + process.env.FLUTTER_ROOT + ? path.join(process.env.FLUTTER_ROOT, "bin", wrapperName) + : null, + process.env.FLUTTER_ROOT + ? path.join( + process.env.FLUTTER_ROOT, + "bin", + "cache", + "dart-sdk", + "bin", + executableName, + ) + : null, + ].filter((candidate): candidate is string => Boolean(candidate)); +} + +function resolveFlutterSdkCommands(): string[] { + const flutterCommand = + process.platform === "win32" ? "flutter.bat" : "flutter"; + const locateCommand = process.platform === "win32" ? "where" : "which"; + const locateResult = spawnSync(locateCommand, [flutterCommand], { + encoding: "utf8", + shell: process.platform === "win32", + }); + if (locateResult.status !== 0 || !locateResult.stdout) { + return []; + } + + const flutterExecutablePath = locateResult.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean); + if (!flutterExecutablePath) { + return []; + } + + const flutterBinDir = path.dirname(flutterExecutablePath); + const executableName = process.platform === "win32" ? "dart.exe" : "dart"; + const wrapperName = process.platform === "win32" ? "dart.bat" : "dart"; + + return [ + path.join(flutterBinDir, wrapperName), + path.join(flutterBinDir, "cache", "dart-sdk", "bin", executableName), + ]; +} + +function resolveDartCommand(): ResolvedDartCommand | null { + const pathCommand = process.platform === "win32" ? "dart.bat" : "dart"; + const shell = process.platform === "win32"; + if (canExecute(pathCommand, shell)) { + return { + command: pathCommand, + shell, + }; + } + + for (const candidate of [ + ...getEnvCandidateCommands(), + ...resolveFlutterSdkCommands(), + ]) { + if (!canExecute(candidate, false)) { + continue; + } + + return { + command: candidate, + shell: false, + }; + } + + return null; +} + +function resolveTextDocumentSyncMode(result: unknown): "full" | "incremental" { + const textDocumentSync = ( + result as { + capabilities?: { + textDocumentSync?: + | number + | { + change?: number; + }; + }; + } + )?.capabilities?.textDocumentSync; + + if (typeof textDocumentSync === "number") { + return textDocumentSync === 2 ? "incremental" : "full"; + } + + if ( + textDocumentSync && + typeof textDocumentSync === "object" && + textDocumentSync.change === 2 + ) { + return "incremental"; + } + + return "full"; +} + +export class DartLanguageProvider implements LanguageServiceProvider { + readonly id = "dart"; + + readonly label = "Dart"; + + readonly description = + "Dart and Flutter diagnostics via the Dart language server."; + + readonly languageIds = ["dart"]; + + private readonly sessions = new Map(); + + private readonly workspaceErrors = new Map(); + + supportsLanguage(languageId: string): boolean { + return languageId === "dart"; + } + + async openDocument(document: LanguageServiceDocument): Promise { + const session = await this.ensureSession( + document.workspaceId, + document.workspacePath, + ); + const uri = absolutePathToFileUri(document.absolutePath); + session.openDocuments.set(document.absolutePath, { + languageId: document.languageId, + version: document.version, + content: document.content, + uri, + }); + await session.client.notify("textDocument/didOpen", { + textDocument: { + uri, + languageId: "dart", + version: document.version, + text: document.content, + }, + }); + } + + async changeDocument(document: LanguageServiceDocument): Promise { + const session = await this.ensureSession( + document.workspaceId, + document.workspacePath, + ); + const previous = session.openDocuments.get(document.absolutePath); + if (!previous) { + await this.openDocument(document); + return; + } + + session.openDocuments.set(document.absolutePath, { + languageId: document.languageId, + version: document.version, + content: document.content, + uri: previous.uri, + }); + + await session.client.notify("textDocument/didChange", { + textDocument: { + uri: previous.uri, + version: document.version, + }, + contentChanges: + session.textDocumentSyncMode === "incremental" + ? [ + { + range: { + start: { line: 0, character: 0 }, + end: offsetToLspPosition( + previous.content, + previous.content.length, + ), + }, + text: document.content, + }, + ] + : [ + { + text: document.content, + }, + ], + }); + } + + async closeDocument(args: { + workspaceId: string; + workspacePath: string; + absolutePath: string; + languageId: string; + }): Promise { + const session = this.sessions.get(args.workspaceId); + if (!session) { + return; + } + + const existing = session.openDocuments.get(args.absolutePath); + session.openDocuments.delete(args.absolutePath); + languageDiagnosticsStore.clearFileDiagnostics( + args.workspaceId, + this.fileKey(args.absolutePath), + ); + + if (existing) { + await session.client.notify("textDocument/didClose", { + textDocument: { + uri: existing.uri, + }, + }); + } + + if (session.openDocuments.size === 0) { + await this.disposeWorkspace(args); + } + } + + async refreshWorkspace(args: { + workspaceId: string; + workspacePath: string; + }): Promise { + const session = this.sessions.get(args.workspaceId); + if (!session) { + return; + } + + try { + await session.client.request("dart/reanalyze"); + session.lastError = null; + } catch (error) { + session.lastError = + error instanceof Error ? error.message : String(error); + this.workspaceErrors.set(args.workspaceId, session.lastError); + } + } + + getWorkspaceSummary(args: { + workspaceId: string; + workspacePath: string; + enabled: boolean; + }): LanguageServiceProviderSummary { + const session = this.sessions.get(args.workspaceId); + const lastError = + session?.lastError ?? this.workspaceErrors.get(args.workspaceId) ?? null; + + if (!args.enabled) { + return { + providerId: this.id, + label: this.label, + status: "disabled", + details: null, + documentCount: 0, + }; + } + + if (!session) { + return { + providerId: this.id, + label: this.label, + status: lastError ? "error" : "idle", + details: lastError, + documentCount: 0, + }; + } + + return { + providerId: this.id, + label: this.label, + status: lastError ? "error" : "ready", + details: lastError, + documentCount: session.openDocuments.size, + }; + } + + async disposeWorkspace(args: { + workspaceId: string; + workspacePath: string; + }): Promise { + const session = this.sessions.get(args.workspaceId); + if (session) { + await session.client.stop(); + this.sessions.delete(args.workspaceId); + } + + this.workspaceErrors.delete(args.workspaceId); + } + + private async ensureSession( + workspaceId: string, + workspacePath: string, + ): Promise { + const existing = this.sessions.get(workspaceId); + if (existing) { + return existing; + } + + const resolvedDartCommand = resolveDartCommand(); + if (!resolvedDartCommand) { + const error = + "dart command not found. Install Dart or Flutter, or set DART_SDK / FLUTTER_ROOT."; + this.workspaceErrors.set(workspaceId, error); + throw new Error(error); + } + + let session!: WorkspaceSession; + const client = new StdioJsonRpcClient({ + name: `dart:${workspaceId}`, + command: resolvedDartCommand.command, + args: [ + "language-server", + "--client-id", + "superset.desktop", + "--client-version", + "1.4.6", + ], + cwd: workspacePath, + env: process.env, + shell: resolvedDartCommand.shell, + onNotification: (message) => { + this.handleNotification(session, message); + }, + onRequest: async (message) => await this.handleServerRequest(message), + onExit: ({ code, signal }) => { + const error = `dart language-server exited (${code ?? "null"}${signal ? `, ${signal}` : ""})`; + session.lastError = error; + this.workspaceErrors.set(workspaceId, error); + this.sessions.delete(workspaceId); + }, + onStderr: (chunk) => { + console.error("[language-services/dart] stderr", { + workspaceId, + chunk, + }); + }, + }); + + session = { + workspaceId, + workspacePath, + dartCommand: resolvedDartCommand.command, + client, + openDocuments: new Map(), + lastError: null, + textDocumentSyncMode: "full", + }; + + try { + await client.start(); + const workspaceUri = absolutePathToFileUri(workspacePath); + const initializeResult = await client.request("initialize", { + processId: process.pid, + clientInfo: { + name: "Superset Desktop", + version: "1.4.6", + }, + rootUri: workspaceUri, + rootPath: workspacePath, + workspaceFolders: [ + { + uri: workspaceUri, + name: path.basename(workspacePath), + }, + ], + capabilities: { + workspace: { + configuration: true, + workspaceFolders: true, + }, + textDocument: { + publishDiagnostics: { + relatedInformation: true, + }, + }, + }, + initializationOptions: { + onlyAnalyzeProjectsWithOpenFiles: true, + }, + }); + await client.notify("initialized", {}); + session.textDocumentSyncMode = + resolveTextDocumentSyncMode(initializeResult); + session.lastError = null; + this.workspaceErrors.delete(workspaceId); + this.sessions.set(workspaceId, session); + return session; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + session.lastError = message; + this.workspaceErrors.set(workspaceId, message); + await client.stop(); + throw error; + } + } + + private handleNotification( + session: WorkspaceSession, + message: { + method: string; + params?: unknown; + }, + ): void { + if (message.method !== "textDocument/publishDiagnostics") { + return; + } + + const params = message.params as + | { + uri?: string; + diagnostics?: DartDiagnostic[]; + } + | undefined; + if (!params?.uri) { + return; + } + + const absolutePath = fileUriToAbsolutePath(params.uri); + if (!absolutePath) { + return; + } + + languageDiagnosticsStore.setFileDiagnostics( + session.workspaceId, + this.fileKey(absolutePath), + (params.diagnostics ?? []).map((diagnostic) => + this.mapDiagnostic(session.workspacePath, absolutePath, diagnostic), + ), + ); + } + + private async handleServerRequest(message: { + method: string; + params?: unknown; + }): Promise { + if (message.method !== "workspace/configuration") { + return undefined; + } + + const items = (( + message.params as { items?: Array<{ section?: string | null }> | null } + )?.items ?? []) as Array<{ section?: string | null }>; + return items.map((item) => { + if (item.section === "dart") { + return { + showTodos: false, + }; + } + + return null; + }); + } + + private mapDiagnostic( + workspacePath: string, + absolutePath: string, + diagnostic: DartDiagnostic, + ): LanguageServiceDiagnostic { + const relatedInformation = ( + diagnostic.relatedInformation ?? [] + ).map((item) => { + const relatedAbsolutePath = + fileUriToAbsolutePath(item.location.uri) ?? absolutePath; + return { + absolutePath: relatedAbsolutePath, + relativePath: toRelativeWorkspacePath( + workspacePath, + relatedAbsolutePath, + ), + line: item.location.range.start.line + 1, + column: item.location.range.start.character + 1, + endLine: item.location.range.end.line + 1, + endColumn: item.location.range.end.character + 1, + message: item.message, + }; + }); + + return { + providerId: this.id, + source: diagnostic.source ?? "dart", + absolutePath, + relativePath: toRelativeWorkspacePath(workspacePath, absolutePath), + line: diagnostic.range.start.line + 1, + column: diagnostic.range.start.character + 1, + endLine: diagnostic.range.end.line + 1, + endColumn: diagnostic.range.end.character + 1, + message: diagnostic.message, + code: diagnostic.code ?? null, + severity: lspSeverityToLanguageServiceSeverity(diagnostic.severity), + relatedInformation, + }; + } + + private fileKey(absolutePath: string): string { + return `${this.id}::${absolutePath}`; + } +} diff --git a/apps/desktop/src/main/lib/language-services/providers/json/JsonLanguageProvider.ts b/apps/desktop/src/main/lib/language-services/providers/json/JsonLanguageProvider.ts new file mode 100644 index 00000000000..d8307306d69 --- /dev/null +++ b/apps/desktop/src/main/lib/language-services/providers/json/JsonLanguageProvider.ts @@ -0,0 +1,306 @@ +import fs from "node:fs/promises"; +import { + type Diagnostic, + getLanguageService, +} from "vscode-json-languageservice"; +import { TextDocument } from "vscode-languageserver-textdocument"; +import { languageDiagnosticsStore } from "../../diagnostics-store"; +import type { + LanguageServiceDiagnostic, + LanguageServiceDocument, + LanguageServiceProvider, + LanguageServiceProviderSummary, +} from "../../types"; +import { + absolutePathToFileUri, + fileUriToAbsolutePath, + lspSeverityToLanguageServiceSeverity, + toRelativeWorkspacePath, +} from "../../utils"; + +type OpenDocumentEntry = { + languageId: string; + version: number; + content: string; +}; + +type WorkspaceState = { + documents: Map; + lastError: string | null; +}; + +const KNOWN_JSON_SCHEMAS = [ + { + uri: "https://json.schemastore.org/package.json", + fileMatch: ["package.json"], + }, + { + uri: "https://json.schemastore.org/tsconfig.json", + fileMatch: ["tsconfig.json", "tsconfig.*.json"], + }, + { + uri: "https://json.schemastore.org/jsconfig.json", + fileMatch: ["jsconfig.json"], + }, + { + uri: "https://json.schemastore.org/bunfig.json", + fileMatch: ["bunfig.json", "bunfig.*.json"], + }, + { + uri: "https://json.schemastore.org/turbo.json", + fileMatch: ["turbo.json"], + }, +]; + +export class JsonLanguageProvider implements LanguageServiceProvider { + readonly id = "json"; + + readonly label = "JSON"; + + readonly description = + "JSON and JSONC diagnostics via vscode-json-languageservice."; + + readonly languageIds = ["json", "jsonc"]; + + private readonly workspaces = new Map(); + + private readonly jsonService = getLanguageService({ + schemaRequestService: async (uri) => { + if (uri.startsWith("file://")) { + return await fs.readFile(new URL(uri), "utf8"); + } + + const response = await fetch(uri); + if (!response.ok) { + throw new Error(`Failed to load schema: ${uri} (${response.status})`); + } + + return await response.text(); + }, + }); + + constructor() { + this.jsonService.configure({ + validate: true, + allowComments: false, + schemas: KNOWN_JSON_SCHEMAS, + }); + } + + supportsLanguage(languageId: string): boolean { + return languageId === "json" || languageId === "jsonc"; + } + + async openDocument(document: LanguageServiceDocument): Promise { + const workspaceState = this.getOrCreateWorkspaceState(document.workspaceId); + workspaceState.documents.set(document.absolutePath, { + languageId: document.languageId, + version: document.version, + content: document.content, + }); + await this.validateDocument(document, workspaceState); + } + + async changeDocument(document: LanguageServiceDocument): Promise { + const workspaceState = this.getOrCreateWorkspaceState(document.workspaceId); + workspaceState.documents.set(document.absolutePath, { + languageId: document.languageId, + version: document.version, + content: document.content, + }); + await this.validateDocument(document, workspaceState); + } + + async closeDocument(args: { + workspaceId: string; + workspacePath: string; + absolutePath: string; + languageId: string; + }): Promise { + const workspaceState = this.workspaces.get(args.workspaceId); + if (!workspaceState) { + return; + } + + workspaceState.documents.delete(args.absolutePath); + languageDiagnosticsStore.clearFileDiagnostics( + args.workspaceId, + this.fileKey(args.absolutePath), + ); + + if (workspaceState.documents.size === 0) { + this.workspaces.delete(args.workspaceId); + } + } + + async refreshWorkspace(args: { + workspaceId: string; + workspacePath: string; + }): Promise { + const workspaceState = this.workspaces.get(args.workspaceId); + if (!workspaceState) { + return; + } + + for (const [absolutePath, entry] of workspaceState.documents.entries()) { + await this.validateDocument( + { + workspaceId: args.workspaceId, + workspacePath: args.workspacePath, + absolutePath, + languageId: entry.languageId, + content: entry.content, + version: entry.version, + }, + workspaceState, + ); + } + } + + getWorkspaceSummary(args: { + workspaceId: string; + workspacePath: string; + enabled: boolean; + }): LanguageServiceProviderSummary { + const workspaceState = this.workspaces.get(args.workspaceId); + if (!args.enabled) { + return { + providerId: this.id, + label: this.label, + status: "disabled", + details: null, + documentCount: 0, + }; + } + + if (!workspaceState) { + return { + providerId: this.id, + label: this.label, + status: "idle", + details: null, + documentCount: 0, + }; + } + + return { + providerId: this.id, + label: this.label, + status: workspaceState.lastError ? "error" : "ready", + details: workspaceState.lastError, + documentCount: workspaceState.documents.size, + }; + } + + async disposeWorkspace(args: { + workspaceId: string; + workspacePath: string; + }): Promise { + this.workspaces.delete(args.workspaceId); + } + + private getOrCreateWorkspaceState(workspaceId: string): WorkspaceState { + const existing = this.workspaces.get(workspaceId); + if (existing) { + return existing; + } + + const next: WorkspaceState = { + documents: new Map(), + lastError: null, + }; + this.workspaces.set(workspaceId, next); + return next; + } + + private async validateDocument( + document: LanguageServiceDocument, + workspaceState: WorkspaceState, + ): Promise { + try { + const textDocument = TextDocument.create( + absolutePathToFileUri(document.absolutePath), + document.languageId, + document.version, + document.content, + ); + const jsonDocument = this.jsonService.parseJSONDocument(textDocument); + const diagnostics = await this.jsonService.doValidation( + textDocument, + jsonDocument, + document.languageId === "jsonc" + ? { + comments: "ignore", + trailingCommas: "ignore", + schemaRequest: "ignore", + } + : { + comments: "error", + trailingCommas: "error", + schemaRequest: "ignore", + }, + ); + workspaceState.lastError = null; + languageDiagnosticsStore.setFileDiagnostics( + document.workspaceId, + this.fileKey(document.absolutePath), + diagnostics.map((diagnostic) => + this.mapDiagnostic( + document.workspacePath, + document.absolutePath, + diagnostic, + ), + ), + ); + } catch (error) { + workspaceState.lastError = + error instanceof Error ? error.message : String(error); + languageDiagnosticsStore.setFileDiagnostics( + document.workspaceId, + this.fileKey(document.absolutePath), + [], + ); + } + } + + private mapDiagnostic( + workspacePath: string, + absolutePath: string, + diagnostic: Diagnostic, + ): LanguageServiceDiagnostic { + return { + providerId: this.id, + source: diagnostic.source ?? "json", + absolutePath, + relativePath: toRelativeWorkspacePath(workspacePath, absolutePath), + line: diagnostic.range.start.line + 1, + column: diagnostic.range.start.character + 1, + endLine: diagnostic.range.end.line + 1, + endColumn: diagnostic.range.end.character + 1, + message: diagnostic.message, + code: diagnostic.code ?? null, + severity: lspSeverityToLanguageServiceSeverity(diagnostic.severity), + relatedInformation: + diagnostic.relatedInformation?.map((item) => { + const relatedAbsolutePath = + fileUriToAbsolutePath(item.location.uri) ?? absolutePath; + return { + absolutePath: relatedAbsolutePath, + relativePath: toRelativeWorkspacePath( + workspacePath, + relatedAbsolutePath, + ), + line: item.location.range.start.line + 1, + column: item.location.range.start.character + 1, + endLine: item.location.range.end.line + 1, + endColumn: item.location.range.end.character + 1, + message: item.message, + }; + }) ?? [], + }; + } + + private fileKey(absolutePath: string): string { + return `${this.id}::${absolutePath}`; + } +} diff --git a/apps/desktop/src/main/lib/language-services/providers/toml/TomlLanguageProvider.ts b/apps/desktop/src/main/lib/language-services/providers/toml/TomlLanguageProvider.ts new file mode 100644 index 00000000000..4d73136bc66 --- /dev/null +++ b/apps/desktop/src/main/lib/language-services/providers/toml/TomlLanguageProvider.ts @@ -0,0 +1,270 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { TextDecoder } from "node:util"; +import { type LintError, Taplo } from "@taplo/lib"; +import { languageDiagnosticsStore } from "../../diagnostics-store"; +import type { + LanguageServiceDiagnostic, + LanguageServiceDocument, + LanguageServiceProvider, + LanguageServiceProviderSummary, +} from "../../types"; +import { offsetToLineColumn, toRelativeWorkspacePath } from "../../utils"; + +type OpenDocumentEntry = { + languageId: string; + version: number; + content: string; +}; + +type WorkspaceState = { + documents: Map; + taploPromise: Promise; + lastError: string | null; +}; + +const decoder = new TextDecoder(); + +function createTaploInstance(workspacePath: string): Promise { + return Taplo.initialize({ + cwd: () => workspacePath, + envVar: (key) => process.env[key] ?? "", + envVars: () => + Object.entries(process.env).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + findConfigFile: () => undefined, + glob: () => [], + isAbsolute: (candidate) => path.isAbsolute(candidate), + now: () => new Date(), + readFile: async (target) => await fs.readFile(target), + writeFile: async () => { + throw new Error("Taplo writeFile is not implemented"); + }, + stderr: async (chunk) => { + console.error( + "[language-services/toml] taplo stderr", + decoder.decode(chunk), + ); + return chunk.length; + }, + stdErrAtty: () => false, + stdin: async () => { + throw new Error("Taplo stdin is not implemented"); + }, + stdout: async (chunk) => chunk.length, + urlToFilePath: (uri) => fileURLToPath(uri), + }); +} + +export class TomlLanguageProvider implements LanguageServiceProvider { + readonly id = "toml"; + + readonly label = "TOML"; + + readonly description = "TOML diagnostics via Taplo."; + + readonly languageIds = ["toml"]; + + private readonly workspaces = new Map(); + + supportsLanguage(languageId: string): boolean { + return languageId === "toml"; + } + + async openDocument(document: LanguageServiceDocument): Promise { + const workspaceState = this.getOrCreateWorkspaceState( + document.workspaceId, + document.workspacePath, + ); + workspaceState.documents.set(document.absolutePath, { + languageId: document.languageId, + version: document.version, + content: document.content, + }); + await this.validateDocument(document, workspaceState); + } + + async changeDocument(document: LanguageServiceDocument): Promise { + const workspaceState = this.getOrCreateWorkspaceState( + document.workspaceId, + document.workspacePath, + ); + workspaceState.documents.set(document.absolutePath, { + languageId: document.languageId, + version: document.version, + content: document.content, + }); + await this.validateDocument(document, workspaceState); + } + + async closeDocument(args: { + workspaceId: string; + workspacePath: string; + absolutePath: string; + languageId: string; + }): Promise { + const workspaceState = this.workspaces.get(args.workspaceId); + if (!workspaceState) { + return; + } + + workspaceState.documents.delete(args.absolutePath); + languageDiagnosticsStore.clearFileDiagnostics( + args.workspaceId, + this.fileKey(args.absolutePath), + ); + + if (workspaceState.documents.size === 0) { + this.workspaces.delete(args.workspaceId); + } + } + + async refreshWorkspace(args: { + workspaceId: string; + workspacePath: string; + }): Promise { + const workspaceState = this.workspaces.get(args.workspaceId); + if (!workspaceState) { + return; + } + + for (const [absolutePath, entry] of workspaceState.documents.entries()) { + await this.validateDocument( + { + workspaceId: args.workspaceId, + workspacePath: args.workspacePath, + absolutePath, + languageId: entry.languageId, + content: entry.content, + version: entry.version, + }, + workspaceState, + ); + } + } + + getWorkspaceSummary(args: { + workspaceId: string; + workspacePath: string; + enabled: boolean; + }): LanguageServiceProviderSummary { + const workspaceState = this.workspaces.get(args.workspaceId); + if (!args.enabled) { + return { + providerId: this.id, + label: this.label, + status: "disabled", + details: null, + documentCount: 0, + }; + } + + if (!workspaceState) { + return { + providerId: this.id, + label: this.label, + status: "idle", + details: null, + documentCount: 0, + }; + } + + return { + providerId: this.id, + label: this.label, + status: workspaceState.lastError ? "error" : "ready", + details: workspaceState.lastError, + documentCount: workspaceState.documents.size, + }; + } + + async disposeWorkspace(args: { + workspaceId: string; + workspacePath: string; + }): Promise { + this.workspaces.delete(args.workspaceId); + } + + private getOrCreateWorkspaceState( + workspaceId: string, + workspacePath: string, + ): WorkspaceState { + const existing = this.workspaces.get(workspaceId); + if (existing) { + return existing; + } + + const next: WorkspaceState = { + documents: new Map(), + taploPromise: createTaploInstance(workspacePath), + lastError: null, + }; + this.workspaces.set(workspaceId, next); + return next; + } + + private async validateDocument( + document: LanguageServiceDocument, + workspaceState: WorkspaceState, + ): Promise { + try { + const taplo = await workspaceState.taploPromise; + const result = await taplo.lint(document.content); + workspaceState.lastError = null; + languageDiagnosticsStore.setFileDiagnostics( + document.workspaceId, + this.fileKey(document.absolutePath), + result.errors.map((error) => this.mapDiagnostic(document, error)), + ); + } catch (error) { + workspaceState.lastError = + error instanceof Error ? error.message : String(error); + languageDiagnosticsStore.setFileDiagnostics( + document.workspaceId, + this.fileKey(document.absolutePath), + [], + ); + } + } + + private mapDiagnostic( + document: LanguageServiceDocument, + error: LintError, + ): LanguageServiceDiagnostic { + const byteRange = error.range as + | { + start?: number; + end?: number; + } + | undefined; + const start = offsetToLineColumn( + document.content, + byteRange?.start ?? null, + ); + const end = offsetToLineColumn(document.content, byteRange?.end ?? null); + + return { + providerId: this.id, + source: "toml", + absolutePath: document.absolutePath, + relativePath: toRelativeWorkspacePath( + document.workspacePath, + document.absolutePath, + ), + line: start.line, + column: start.column, + endLine: end.line, + endColumn: end.column, + message: error.error, + code: null, + severity: "error", + relatedInformation: [], + }; + } + + private fileKey(absolutePath: string): string { + return `${this.id}::${absolutePath}`; + } +} diff --git a/apps/desktop/src/main/lib/language-services/providers/typescript/TypeScriptLanguageProvider.ts b/apps/desktop/src/main/lib/language-services/providers/typescript/TypeScriptLanguageProvider.ts new file mode 100644 index 00000000000..422574d7fab --- /dev/null +++ b/apps/desktop/src/main/lib/language-services/providers/typescript/TypeScriptLanguageProvider.ts @@ -0,0 +1,734 @@ +import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; +import fs from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { languageDiagnosticsStore } from "../../diagnostics-store"; +import type { + LanguageServiceDiagnostic, + LanguageServiceDocument, + LanguageServiceProvider, + LanguageServiceProviderSummary, + LanguageServiceRelatedInformation, + LanguageServiceSeverity, +} from "../../types"; + +const require = createRequire(import.meta.url); + +type TsServerRequest = { + seq: number; + type: "request"; + command: string; + arguments?: unknown; +}; + +type TsServerEvent = { + type: "event"; + event: string; + body?: unknown; +}; + +type TsServerResponse = { + type: "response"; + request_seq: number; + success: boolean; + command: string; + body?: unknown; + message?: string; +}; + +type TsServerMessage = TsServerEvent | TsServerResponse; + +type TsServerDiagnostic = { + start?: { line: number; offset: number }; + end?: { line: number; offset: number }; + text?: string; + message?: string; + code?: number; + category?: string; + relatedInformation?: Array<{ + span?: { + file?: string; + start?: { line: number; offset: number }; + end?: { line: number; offset: number }; + }; + message?: string; + text?: string; + }>; +}; + +type DiagnosticBucketKey = "syntax" | "semantic" | "suggestion" | "config"; + +type FileDiagnosticBuckets = { + syntax: LanguageServiceDiagnostic[]; + semantic: LanguageServiceDiagnostic[]; + suggestion: LanguageServiceDiagnostic[]; + config: LanguageServiceDiagnostic[]; +}; + +type OpenDocumentEntry = { + languageId: string; + version: number; + content: string; +}; + +type WorkspaceSession = { + workspaceId: string; + workspacePath: string; + tsserverPath: string; + process: ChildProcessWithoutNullStreams; + seq: number; + buffer: string; + requestResolvers: Map< + number, + { + resolve: (value: TsServerResponse) => void; + reject: (error: Error) => void; + } + >; + openDocuments: Map; + diagnosticBuckets: Map; + getErrTimer: ReturnType | null; + lastError: string | null; +}; + +function createEmptyBuckets(): FileDiagnosticBuckets { + return { + syntax: [], + semantic: [], + suggestion: [], + config: [], + }; +} + +function tryConsumeContentLengthMessage( + buffer: string, +): { body: string; rest: string } | null { + const normalizedBuffer = buffer.replace(/^(?:\r?\n)+/, ""); + if (normalizedBuffer !== buffer) { + return tryConsumeContentLengthMessage(normalizedBuffer); + } + + const separatorIndex = buffer.indexOf("\r\n\r\n"); + if (separatorIndex === -1) { + return null; + } + + const header = buffer.slice(0, separatorIndex); + const contentLengthMatch = /Content-Length: (\d+)/i.exec(header); + if (!contentLengthMatch) { + return null; + } + + const contentLength = Number(contentLengthMatch[1]); + const bodyStart = separatorIndex + 4; + const bodyEnd = bodyStart + contentLength; + if (buffer.length < bodyEnd) { + return null; + } + + return { + body: buffer.slice(bodyStart, bodyEnd), + rest: buffer.slice(bodyEnd), + }; +} + +function tryConsumeLineMessage( + buffer: string, +): { body: string; rest: string } | null { + const normalizedBuffer = buffer.replace(/^(?:\r?\n)+/, ""); + if (normalizedBuffer !== buffer) { + return tryConsumeLineMessage(normalizedBuffer); + } + + if (!normalizedBuffer.trimStart().startsWith("{")) { + return null; + } + + const newlineIndex = buffer.indexOf("\n"); + if (newlineIndex === -1) { + return null; + } + + return { + body: buffer.slice(0, newlineIndex).trim(), + rest: buffer.slice(newlineIndex + 1), + }; +} + +function toRelativeWorkspacePath( + workspacePath: string, + absolutePath: string, +): string | null { + const relativePath = path.relative(workspacePath, absolutePath); + if ( + !relativePath || + relativePath.startsWith("..") || + path.isAbsolute(relativePath) + ) { + return null; + } + + return relativePath.split(path.sep).join("/"); +} + +function toSeverity(category: string | undefined): LanguageServiceSeverity { + switch (category) { + case "error": + return "error"; + case "warning": + return "warning"; + case "suggestion": + return "hint"; + default: + return "info"; + } +} + +function resolveBundledTsServerPath(): string { + return require.resolve("typescript/lib/tsserver.js"); +} + +function resolveWorkspaceTsServerPath(workspacePath: string): string | null { + const candidate = path.join( + workspacePath, + "node_modules", + "typescript", + "lib", + "tsserver.js", + ); + return fs.existsSync(candidate) ? candidate : null; +} + +function computeEndPosition(content: string): { + endLine: number; + endOffset: number; +} { + const lines = content.split(/\r\n|\r|\n/); + return { + endLine: lines.length, + endOffset: (lines.at(-1)?.length ?? 0) + 1, + }; +} + +export class TypeScriptLanguageProvider implements LanguageServiceProvider { + readonly id = "typescript"; + + readonly label = "TypeScript"; + + readonly description = + "TypeScript, JavaScript, TSX, JSX diagnostics via tsserver."; + + readonly languageIds = [ + "typescript", + "typescriptreact", + "javascript", + "javascriptreact", + ]; + + private readonly sessions = new Map(); + + supportsLanguage(languageId: string): boolean { + return [ + "typescript", + "typescriptreact", + "javascript", + "javascriptreact", + ].includes(languageId); + } + + async openDocument(document: LanguageServiceDocument): Promise { + const session = await this.ensureSession( + document.workspaceId, + document.workspacePath, + ); + session.openDocuments.set(document.absolutePath, { + languageId: document.languageId, + version: document.version, + content: document.content, + }); + await this.sendRequest(session, "open", { + file: document.absolutePath, + fileContent: document.content, + projectRootPath: document.workspacePath, + }); + this.scheduleGetErr(session); + } + + async changeDocument(document: LanguageServiceDocument): Promise { + const session = await this.ensureSession( + document.workspaceId, + document.workspacePath, + ); + const previous = session.openDocuments.get(document.absolutePath); + if (!previous) { + await this.openDocument(document); + return; + } + + session.openDocuments.set(document.absolutePath, { + languageId: document.languageId, + version: document.version, + content: document.content, + }); + + await this.sendRequest(session, "change", { + file: document.absolutePath, + line: 1, + offset: 1, + ...computeEndPosition(previous.content), + insertString: document.content, + }); + this.scheduleGetErr(session); + } + + async closeDocument(args: { + workspaceId: string; + workspacePath: string; + absolutePath: string; + languageId: string; + }): Promise { + const session = this.sessions.get(args.workspaceId); + if (!session) { + return; + } + + session.openDocuments.delete(args.absolutePath); + session.diagnosticBuckets.delete(args.absolutePath); + languageDiagnosticsStore.clearFileDiagnostics( + args.workspaceId, + this.fileKey(args.absolutePath), + ); + + try { + await this.sendRequest(session, "close", { + file: args.absolutePath, + }); + } catch (error) { + console.error("[language-services/typescript] Failed to close document", { + workspaceId: args.workspaceId, + absolutePath: args.absolutePath, + error, + }); + } + + if (session.openDocuments.size === 0) { + await this.disposeWorkspace(args); + return; + } + + this.scheduleGetErr(session); + } + + async refreshWorkspace(args: { + workspaceId: string; + workspacePath: string; + }): Promise { + const session = this.sessions.get(args.workspaceId); + if (!session || session.openDocuments.size === 0) { + return; + } + + this.scheduleGetErr(session, 0); + } + + getWorkspaceSummary(args: { + workspaceId: string; + workspacePath: string; + enabled: boolean; + }): LanguageServiceProviderSummary { + const session = this.sessions.get(args.workspaceId); + if (!args.enabled) { + return { + providerId: this.id, + label: this.label, + status: "disabled", + details: null, + documentCount: 0, + }; + } + + if (!session) { + return { + providerId: this.id, + label: this.label, + status: "idle", + details: null, + documentCount: 0, + }; + } + + return { + providerId: this.id, + label: this.label, + status: session.lastError ? "error" : "ready", + details: session.lastError, + documentCount: session.openDocuments.size, + }; + } + + async disposeWorkspace(args: { + workspaceId: string; + workspacePath: string; + }): Promise { + const session = this.sessions.get(args.workspaceId); + if (!session) { + return; + } + + if (session.getErrTimer) { + clearTimeout(session.getErrTimer); + session.getErrTimer = null; + } + + for (const request of session.requestResolvers.values()) { + request.reject(new Error("TypeScript session disposed")); + } + session.requestResolvers.clear(); + + session.process.removeAllListeners(); + if (!session.process.killed) { + session.process.kill(); + } + + this.sessions.delete(args.workspaceId); + } + + private async ensureSession( + workspaceId: string, + workspacePath: string, + ): Promise { + const existing = this.sessions.get(workspaceId); + if (existing) { + return existing; + } + + const tsserverPath = + resolveWorkspaceTsServerPath(workspacePath) ?? + resolveBundledTsServerPath(); + const child = spawn(process.execPath, [tsserverPath, "--stdio"], { + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: "1", + }, + stdio: ["pipe", "pipe", "pipe"], + }); + + const session: WorkspaceSession = { + workspaceId, + workspacePath, + tsserverPath, + process: child, + seq: 0, + buffer: "", + requestResolvers: new Map(), + openDocuments: new Map(), + diagnosticBuckets: new Map(), + getErrTimer: null, + lastError: null, + }; + let isSessionClosed = false; + const closeSession = (message: string) => { + if (isSessionClosed) { + return; + } + isSessionClosed = true; + session.lastError = message; + if (session.getErrTimer) { + clearTimeout(session.getErrTimer); + session.getErrTimer = null; + } + for (const request of session.requestResolvers.values()) { + request.reject(new Error(message)); + } + session.requestResolvers.clear(); + this.sessions.delete(workspaceId); + }; + + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + this.handleStdout(session, chunk); + }); + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => { + console.error("[language-services/typescript] tsserver stderr", { + workspaceId, + chunk, + }); + }); + child.on("error", (error) => { + console.error("[language-services/typescript] tsserver process error", { + workspaceId, + error, + }); + closeSession( + error instanceof Error + ? `tsserver process error: ${error.message}` + : "tsserver process error", + ); + }); + child.on("exit", (code, signal) => { + closeSession( + `TypeScript server exited: ${code ?? "null"}${signal ? ` ${signal}` : ""}`, + ); + }); + + this.sessions.set(workspaceId, session); + await this.sendRequest(session, "configure", { + preferences: { + includeCompletionsForModuleExports: true, + includeCompletionsWithInsertText: true, + }, + }); + return session; + } + + private handleStdout(session: WorkspaceSession, chunk: string): void { + session.buffer += chunk; + while (true) { + const framedMessage = tryConsumeContentLengthMessage(session.buffer); + const lineMessage = + framedMessage === null ? tryConsumeLineMessage(session.buffer) : null; + const message = framedMessage ?? lineMessage; + if (!message) { + return; + } + + session.buffer = message.rest; + const body = message.body.trim(); + if (!body) { + continue; + } + + try { + const message = JSON.parse(body) as TsServerMessage; + this.handleMessage(session, message); + } catch (error) { + console.error( + "[language-services/typescript] Failed to parse tsserver payload", + { + workspaceId: session.workspaceId, + error, + body, + }, + ); + } + } + } + + private handleMessage( + session: WorkspaceSession, + message: TsServerMessage, + ): void { + if (message.type === "response") { + const resolver = session.requestResolvers.get(message.request_seq); + if (!resolver) { + return; + } + session.requestResolvers.delete(message.request_seq); + if (message.success) { + session.lastError = null; + resolver.resolve(message); + } else { + const error = new Error( + message.message ?? `tsserver command failed: ${message.command}`, + ); + session.lastError = error.message; + resolver.reject(error); + } + return; + } + + switch (message.event) { + case "syntaxDiag": + this.applyDiagnosticsEvent(session, "syntax", message.body); + return; + case "semanticDiag": + this.applyDiagnosticsEvent(session, "semantic", message.body); + return; + case "suggestionDiag": + this.applyDiagnosticsEvent(session, "suggestion", message.body); + return; + case "configFileDiag": + this.applyConfigDiagnosticsEvent(session, message.body); + return; + default: + return; + } + } + + private applyDiagnosticsEvent( + session: WorkspaceSession, + bucketKey: DiagnosticBucketKey, + body: unknown, + ): void { + const payload = body as + | { file?: string; diagnostics?: TsServerDiagnostic[] } + | undefined; + if (!payload?.file) { + return; + } + + const absolutePath = payload.file; + const buckets = + session.diagnosticBuckets.get(absolutePath) ?? createEmptyBuckets(); + buckets[bucketKey] = (payload.diagnostics ?? []).map((diagnostic) => + this.mapDiagnostic(session.workspacePath, absolutePath, diagnostic), + ); + session.diagnosticBuckets.set(absolutePath, buckets); + this.publishDiagnostics(session, absolutePath, buckets); + } + + private applyConfigDiagnosticsEvent( + session: WorkspaceSession, + body: unknown, + ): void { + const payload = body as + | { + triggerFile?: string; + configFile?: string; + diagnostics?: TsServerDiagnostic[]; + } + | undefined; + const absolutePath = payload?.configFile ?? payload?.triggerFile; + if (!absolutePath) { + return; + } + if (!payload) { + return; + } + + const buckets = + session.diagnosticBuckets.get(absolutePath) ?? createEmptyBuckets(); + buckets.config = (payload.diagnostics ?? []).map((diagnostic) => + this.mapDiagnostic(session.workspacePath, absolutePath, diagnostic), + ); + session.diagnosticBuckets.set(absolutePath, buckets); + this.publishDiagnostics(session, absolutePath, buckets); + } + + private publishDiagnostics( + session: WorkspaceSession, + absolutePath: string, + buckets: FileDiagnosticBuckets, + ): void { + const diagnostics = [ + ...buckets.syntax, + ...buckets.semantic, + ...buckets.suggestion, + ...buckets.config, + ]; + languageDiagnosticsStore.setFileDiagnostics( + session.workspaceId, + this.fileKey(absolutePath), + diagnostics, + ); + } + + private mapDiagnostic( + workspacePath: string, + absolutePath: string, + diagnostic: TsServerDiagnostic, + ): LanguageServiceDiagnostic { + const relatedInformation = diagnostic.relatedInformation + ?.map((item) => + this.mapRelatedInformation(workspacePath, absolutePath, item), + ) + .filter( + (item): item is LanguageServiceRelatedInformation => item !== null, + ); + + return { + providerId: this.id, + source: "typescript", + absolutePath, + relativePath: toRelativeWorkspacePath(workspacePath, absolutePath), + line: diagnostic.start?.line ?? null, + column: diagnostic.start?.offset ?? null, + endLine: diagnostic.end?.line ?? null, + endColumn: diagnostic.end?.offset ?? null, + message: + diagnostic.text ?? diagnostic.message ?? "Unknown TypeScript error", + code: diagnostic.code ?? null, + severity: toSeverity(diagnostic.category), + relatedInformation, + }; + } + + private mapRelatedInformation( + workspacePath: string, + fallbackAbsolutePath: string, + item: NonNullable[number], + ): LanguageServiceRelatedInformation | null { + const absolutePath = item.span?.file ?? fallbackAbsolutePath; + const message = item.text ?? item.message ?? ""; + if (!message) { + return null; + } + + return { + absolutePath, + relativePath: toRelativeWorkspacePath(workspacePath, absolutePath), + line: item.span?.start?.line ?? null, + column: item.span?.start?.offset ?? null, + endLine: item.span?.end?.line ?? null, + endColumn: item.span?.end?.offset ?? null, + message, + }; + } + + private scheduleGetErr(session: WorkspaceSession, delay = 150): void { + if (session.getErrTimer) { + clearTimeout(session.getErrTimer); + } + + session.getErrTimer = setTimeout(() => { + session.getErrTimer = null; + if (session.openDocuments.size === 0) { + return; + } + + void this.sendRequest(session, "geterr", { + files: Array.from(session.openDocuments.keys()), + delay: 0, + }).catch((error) => { + session.lastError = + error instanceof Error ? error.message : String(error); + console.error("[language-services/typescript] geterr failed", { + workspaceId: session.workspaceId, + error, + }); + }); + }, delay); + } + + private async sendRequest( + session: WorkspaceSession, + command: string, + args?: unknown, + ): Promise { + const seq = ++session.seq; + const payload: TsServerRequest = { + seq, + type: "request", + command, + arguments: args, + }; + const content = `${JSON.stringify(payload)}\n`; + + return await new Promise((resolve, reject) => { + session.requestResolvers.set(seq, { resolve, reject }); + session.process.stdin.write(content, "utf8", (error) => { + if (!error) { + return; + } + + session.requestResolvers.delete(seq); + reject(error); + }); + }); + } + + private fileKey(absolutePath: string): string { + return `${this.id}::${absolutePath}`; + } +} diff --git a/apps/desktop/src/main/lib/language-services/types.ts b/apps/desktop/src/main/lib/language-services/types.ts new file mode 100644 index 00000000000..df3aeb7cffe --- /dev/null +++ b/apps/desktop/src/main/lib/language-services/types.ts @@ -0,0 +1,96 @@ +export type LanguageServiceSeverity = "error" | "warning" | "info" | "hint"; + +export interface LanguageServiceRelatedInformation { + absolutePath: string | null; + relativePath: string | null; + line: number | null; + column: number | null; + endLine: number | null; + endColumn: number | null; + message: string; +} + +export interface LanguageServiceDocument { + workspaceId: string; + workspacePath: string; + absolutePath: string; + languageId: string; + content: string; + version: number; +} + +export interface LanguageServiceDiagnostic { + providerId: string; + source: string; + absolutePath: string | null; + relativePath: string | null; + line: number | null; + column: number | null; + endLine: number | null; + endColumn: number | null; + message: string; + code: string | number | null; + severity: LanguageServiceSeverity; + relatedInformation?: LanguageServiceRelatedInformation[]; +} + +export interface LanguageServiceProviderSummary { + providerId: string; + label: string; + status: "ready" | "disabled" | "idle" | "error"; + details?: string | null; + documentCount: number; +} + +export interface LanguageServiceProviderDescriptor { + providerId: string; + label: string; + description: string; + languageIds: string[]; + enabled: boolean; +} + +export interface LanguageServiceWorkspaceSnapshot { + status: "ready"; + workspaceId: string; + workspacePath: string; + providers: LanguageServiceProviderSummary[]; + problems: LanguageServiceDiagnostic[]; + totalCount: number; + truncated: boolean; + summary: { + errorCount: number; + warningCount: number; + infoCount: number; + hintCount: number; + }; +} + +export interface LanguageServiceProvider { + readonly id: string; + readonly label: string; + readonly description: string; + readonly languageIds: string[]; + supportsLanguage(languageId: string): boolean; + openDocument(document: LanguageServiceDocument): Promise; + changeDocument(document: LanguageServiceDocument): Promise; + closeDocument(args: { + workspaceId: string; + workspacePath: string; + absolutePath: string; + languageId: string; + }): Promise; + refreshWorkspace(args: { + workspaceId: string; + workspacePath: string; + }): Promise; + getWorkspaceSummary(args: { + workspaceId: string; + workspacePath: string; + enabled: boolean; + }): LanguageServiceProviderSummary; + disposeWorkspace(args: { + workspaceId: string; + workspacePath: string; + }): Promise; +} diff --git a/apps/desktop/src/main/lib/language-services/utils.ts b/apps/desktop/src/main/lib/language-services/utils.ts new file mode 100644 index 00000000000..11db9d2f396 --- /dev/null +++ b/apps/desktop/src/main/lib/language-services/utils.ts @@ -0,0 +1,105 @@ +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import type { LanguageServiceSeverity } from "./types"; + +export function toRelativeWorkspacePath( + workspacePath: string, + absolutePath: string, +): string | null { + const relativePath = path.relative(workspacePath, absolutePath); + if ( + !relativePath || + relativePath.startsWith("..") || + path.isAbsolute(relativePath) + ) { + return null; + } + + return relativePath.split(path.sep).join("/"); +} + +export function absolutePathToFileUri(absolutePath: string): string { + return pathToFileURL(absolutePath).toString(); +} + +export function fileUriToAbsolutePath(uri: string): string | null { + if (!uri.startsWith("file://")) { + return null; + } + + try { + return fileURLToPath(uri); + } catch { + return null; + } +} + +export function offsetToLineColumn( + content: string, + offset: number | null | undefined, +): { line: number | null; column: number | null } { + if (offset === null || offset === undefined || Number.isNaN(offset)) { + return { + line: null, + column: null, + }; + } + + const boundedOffset = Math.max(0, Math.min(offset, content.length)); + let line = 1; + let column = 1; + + for (let index = 0; index < boundedOffset; index += 1) { + const char = content[index]; + if (char === "\n") { + line += 1; + column = 1; + continue; + } + + if (char === "\r") { + if (content[index + 1] === "\n") { + index += 1; + } + line += 1; + column = 1; + continue; + } + + column += 1; + } + + return { + line, + column, + }; +} + +export function offsetToLspPosition( + content: string, + offset: number, +): { + line: number; + character: number; +} { + const position = offsetToLineColumn(content, offset); + return { + line: Math.max((position.line ?? 1) - 1, 0), + character: Math.max((position.column ?? 1) - 1, 0), + }; +} + +export function lspSeverityToLanguageServiceSeverity( + severity: number | null | undefined, +): LanguageServiceSeverity { + switch (severity) { + case 1: + return "error"; + case 2: + return "warning"; + case 3: + return "info"; + default: + return "hint"; + } +} diff --git a/apps/desktop/src/main/lib/menu-events.ts b/apps/desktop/src/main/lib/menu-events.ts index 7798b468b57..7e848731bb8 100644 --- a/apps/desktop/src/main/lib/menu-events.ts +++ b/apps/desktop/src/main/lib/menu-events.ts @@ -1,4 +1,5 @@ import { EventEmitter } from "node:events"; +import type { BrowserShortcutAction } from "shared/browser-shortcuts"; export type SettingsSection = | "project" | "workspace" @@ -17,4 +18,8 @@ export interface OpenWorkspaceEvent { workspaceId: string; } +export interface BrowserActionEvent { + action: BrowserShortcutAction; +} + export const menuEmitter = new EventEmitter(); diff --git a/apps/desktop/src/main/lib/menu.ts b/apps/desktop/src/main/lib/menu.ts index a312d4124ac..bedd0517719 100644 --- a/apps/desktop/src/main/lib/menu.ts +++ b/apps/desktop/src/main/lib/menu.ts @@ -1,9 +1,10 @@ import { COMPANY } from "@superset/shared/constants"; -import { app, BrowserWindow, Menu, shell } from "electron"; +import { app, BrowserWindow, Menu, shell, webContents } from "electron"; import { env } from "main/env.main"; import { appState } from "main/lib/app-state"; import { hotkeysEmitter } from "main/lib/hotkeys-events"; import { resetTerminalStateDev } from "main/lib/terminal/dev-reset"; +import type { BrowserShortcutAction } from "shared/browser-shortcuts"; import { getCurrentPlatform, getEffectiveHotkey, @@ -20,6 +21,26 @@ import { menuEmitter } from "./menu-events"; let isHotkeyListenerRegistered = false; +function getFocusedWebview() { + return webContents + .getAllWebContents() + .find((wc) => wc.getType() === "webview" && wc.isFocused()); +} + +function triggerBrowserShortcut(action: BrowserShortcutAction) { + const focusedGuest = getFocusedWebview(); + if (focusedGuest) { + if (action === "hard-reload") { + focusedGuest.reloadIgnoringCache(); + } else { + focusedGuest.reload(); + } + return; + } + + menuEmitter.emit("browser-action", action); +} + function getMenuAccelerator(id: HotkeyId): string | undefined { const platform = getCurrentPlatform(); const overrides = appState.data.hotkeysState.byPlatform[platform]; @@ -38,6 +59,10 @@ export function registerMenuHotkeyUpdates() { export function createApplicationMenu() { const reloadAccelerator = getMenuAccelerator("RELOAD_WINDOW"); + const browserReloadAccelerator = getMenuAccelerator("BROWSER_RELOAD"); + const browserHardReloadAccelerator = getMenuAccelerator( + "BROWSER_HARD_RELOAD", + ); const closeAccelerator = getMenuAccelerator("CLOSE_WINDOW"); const showHotkeysAccelerator = getMenuAccelerator("SHOW_HOTKEYS"); const openSettingsAccelerator = getMenuAccelerator("OPEN_SETTINGS"); @@ -75,6 +100,25 @@ export function createApplicationMenu() { { role: "togglefullscreen" }, ], }, + { + label: "Browser", + submenu: [ + { + label: "Reload Browser", + accelerator: browserReloadAccelerator, + click: () => { + triggerBrowserShortcut("reload"); + }, + }, + { + label: "Hard Reload Browser", + accelerator: browserHardReloadAccelerator, + click: () => { + triggerBrowserShortcut("hard-reload"); + }, + }, + ], + }, { label: "Window", submenu: [ diff --git a/apps/desktop/src/main/lib/terminal/env.ts b/apps/desktop/src/main/lib/terminal/env.ts index 883582ba935..4d2c1d46001 100644 --- a/apps/desktop/src/main/lib/terminal/env.ts +++ b/apps/desktop/src/main/lib/terminal/env.ts @@ -1,9 +1,12 @@ import { exec } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; +import { settings } from "@superset/local-db"; import defaultShell from "default-shell"; +import { DEFAULT_PREVENT_AGENT_SLEEP } from "shared/constants"; import { env } from "shared/env.shared"; import { getShellEnv } from "../agent-setup/shell-wrappers"; +import { localDb } from "../local-db"; const MACOS_SYSTEM_CERT_FILE = "/etc/ssl/cert.pem"; let cachedUtf8Locale: string | null = null; @@ -460,6 +463,9 @@ export function buildTerminalEnv(params: { // COLORFGBG: "foreground;background" ANSI color indices — TUI apps use this to detect light/dark const colorFgBg = themeType === "light" ? "0;15" : "15;0"; + const preventAgentSleepSetting = + localDb.select().from(settings).get()?.preventAgentSleep ?? + DEFAULT_PREVENT_AGENT_SLEEP; const terminalEnv: Record = { ...baseEnv, @@ -480,6 +486,7 @@ export function buildTerminalEnv(params: { SUPERSET_ENV: env.NODE_ENV === "development" ? "development" : "production", // Hook protocol version for forward compatibility SUPERSET_HOOK_VERSION: HOOK_PROTOCOL_VERSION, + SUPERSET_PREVENT_AGENT_SLEEP: preventAgentSleepSetting ? "1" : "0", }; delete terminalEnv.GOOGLE_API_KEY; diff --git a/apps/desktop/src/renderer/components/MarkdownRenderer/MarkdownRenderer.tsx b/apps/desktop/src/renderer/components/MarkdownRenderer/MarkdownRenderer.tsx index 7ca1d194c71..4d2567d72f0 100644 --- a/apps/desktop/src/renderer/components/MarkdownRenderer/MarkdownRenderer.tsx +++ b/apps/desktop/src/renderer/components/MarkdownRenderer/MarkdownRenderer.tsx @@ -18,12 +18,14 @@ interface MarkdownRendererProps { content: string; style?: keyof typeof styleConfigs; className?: string; + scrollable?: boolean; } export function MarkdownRenderer({ content, style: styleProp, className, + scrollable = true, }: MarkdownRendererProps) { const globalStyle = useMarkdownStyle(); const style = styleProp ?? globalStyle; @@ -34,7 +36,8 @@ export function MarkdownRenderer({
(BROWSER_SHORTCUT_EVENT, { + detail: { action }, + }), + ); +} + +export function addBrowserShortcutListener( + listener: (action: BrowserShortcutAction) => void, +): () => void { + if ( + typeof window === "undefined" || + typeof window.addEventListener !== "function" + ) { + return () => {}; + } + + const handleEvent = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (!detail) return; + listener(detail.action); + }; + + window.addEventListener(BROWSER_SHORTCUT_EVENT, handleEvent); + + return () => { + window.removeEventListener(BROWSER_SHORTCUT_EVENT, handleEvent); + }; +} diff --git a/apps/desktop/src/renderer/providers/LanguageServicesProvider/LanguageServicesProvider.tsx b/apps/desktop/src/renderer/providers/LanguageServicesProvider/LanguageServicesProvider.tsx new file mode 100644 index 00000000000..bbf7c1c6f97 --- /dev/null +++ b/apps/desktop/src/renderer/providers/LanguageServicesProvider/LanguageServicesProvider.tsx @@ -0,0 +1,234 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; +import { + getDocumentCurrentContent, + hasInitializedDocumentBuffer, +} from "renderer/stores/editor-state/editorBufferRegistry"; +import { useEditorDocumentsStore } from "renderer/stores/editor-state/useEditorDocumentsStore"; +import { + type LanguageServiceProviderId, + useLanguageServicePreferencesStore, +} from "renderer/stores/language-service-preferences"; + +type TrackedDocument = { + documentKey: string; + workspaceId: string; + absolutePath: string; + languageId: string; + content: string; + version: number; +}; + +function resolveLanguageId(absolutePath: string): string | null { + const normalizedPath = absolutePath.toLowerCase().replaceAll("\\", "/"); + const fileName = normalizedPath.split("/").at(-1) ?? normalizedPath; + if (normalizedPath.endsWith(".tsx")) { + return "typescriptreact"; + } + if ( + normalizedPath.endsWith(".ts") || + normalizedPath.endsWith(".mts") || + normalizedPath.endsWith(".cts") + ) { + return "typescript"; + } + if (normalizedPath.endsWith(".jsx")) { + return "javascriptreact"; + } + if ( + normalizedPath.endsWith(".js") || + normalizedPath.endsWith(".mjs") || + normalizedPath.endsWith(".cjs") + ) { + return "javascript"; + } + if ( + normalizedPath.endsWith(".jsonc") || + fileName === "jsconfig.json" || + fileName === "settings.json" || + fileName === "extensions.json" || + fileName === "launch.json" || + fileName === "tasks.json" || + fileName === "keybindings.json" || + /^tsconfig\..+\.json$/.test(fileName) || + fileName === "tsconfig.json" + ) { + return "jsonc"; + } + if (normalizedPath.endsWith(".json")) { + return "json"; + } + if (normalizedPath.endsWith(".toml")) { + return "toml"; + } + if (normalizedPath.endsWith(".dart")) { + return "dart"; + } + return null; +} + +function resolveProviderId( + languageId: string, +): LanguageServiceProviderId | null { + switch (languageId) { + case "typescript": + case "typescriptreact": + case "javascript": + case "javascriptreact": + return "typescript"; + case "json": + case "jsonc": + return "json"; + case "toml": + return "toml"; + case "dart": + return "dart"; + default: + return null; + } +} + +export function LanguageServicesProvider() { + const documentsByKey = useEditorDocumentsStore((state) => state.documents); + const enabledProviders = useLanguageServicePreferencesStore( + (state) => state.enabledProviders, + ); + const hasHydratedPreferences = useLanguageServicePreferencesStore( + (state) => state.hasHydrated, + ); + const previousRef = useRef>(new Map()); + const hasAppliedInitialProviderPreferencesRef = useRef(false); + const [isProviderPreferenceSyncReady, setIsProviderPreferenceSyncReady] = + useState(false); + + useEffect(() => { + if ( + !hasHydratedPreferences || + hasAppliedInitialProviderPreferencesRef.current + ) { + return; + } + + hasAppliedInitialProviderPreferencesRef.current = true; + void Promise.allSettled( + Object.entries(enabledProviders).map(([providerId, enabled]) => + electronTrpcClient.languageServices.setProviderEnabled.mutate({ + providerId, + enabled, + }), + ), + ).finally(() => { + setIsProviderPreferenceSyncReady(true); + }); + }, [enabledProviders, hasHydratedPreferences]); + + const trackedDocuments = useMemo(() => { + const next = new Map(); + if (!hasHydratedPreferences || !isProviderPreferenceSyncReady) { + return next; + } + + for (const document of Object.values(documentsByKey)) { + if ( + document.sessionPaneIds.length === 0 || + document.status === "loading" || + !hasInitializedDocumentBuffer(document.documentKey) + ) { + continue; + } + + const languageId = resolveLanguageId(document.filePath); + if (!languageId) { + continue; + } + + const providerId = resolveProviderId(languageId); + if (providerId && !enabledProviders[providerId]) { + continue; + } + + next.set(document.documentKey, { + documentKey: document.documentKey, + workspaceId: document.workspaceId, + absolutePath: document.filePath, + languageId, + content: getDocumentCurrentContent(document.documentKey), + version: document.contentVersion, + }); + } + + for (const [documentKey, tracked] of next.entries()) { + if (tracked.version === 0 && tracked.content.length === 0) { + next.delete(documentKey); + } + } + + return next; + }, [ + documentsByKey, + enabledProviders, + hasHydratedPreferences, + isProviderPreferenceSyncReady, + ]); + + useEffect(() => { + const previous = previousRef.current; + + for (const [documentKey, tracked] of trackedDocuments.entries()) { + const prev = previous.get(documentKey); + if (!prev) { + void electronTrpcClient.languageServices.openDocument.mutate({ + workspaceId: tracked.workspaceId, + absolutePath: tracked.absolutePath, + languageId: tracked.languageId, + content: tracked.content, + version: tracked.version, + }); + continue; + } + + if ( + prev.version !== tracked.version || + prev.absolutePath !== tracked.absolutePath || + prev.languageId !== tracked.languageId || + prev.workspaceId !== tracked.workspaceId + ) { + void electronTrpcClient.languageServices.changeDocument.mutate({ + workspaceId: tracked.workspaceId, + absolutePath: tracked.absolutePath, + languageId: tracked.languageId, + content: tracked.content, + version: tracked.version, + }); + } + } + + for (const [documentKey, tracked] of previous.entries()) { + if (trackedDocuments.has(documentKey)) { + continue; + } + + void electronTrpcClient.languageServices.closeDocument.mutate({ + workspaceId: tracked.workspaceId, + absolutePath: tracked.absolutePath, + languageId: tracked.languageId, + }); + } + + previousRef.current = trackedDocuments; + }, [trackedDocuments]); + + useEffect(() => { + return () => { + for (const tracked of previousRef.current.values()) { + void electronTrpcClient.languageServices.closeDocument.mutate({ + workspaceId: tracked.workspaceId, + absolutePath: tracked.absolutePath, + languageId: tracked.languageId, + }); + } + }; + }, []); + + return null; +} diff --git a/apps/desktop/src/renderer/providers/LanguageServicesProvider/index.ts b/apps/desktop/src/renderer/providers/LanguageServicesProvider/index.ts new file mode 100644 index 00000000000..94c7bd1ef0a --- /dev/null +++ b/apps/desktop/src/renderer/providers/LanguageServicesProvider/index.ts @@ -0,0 +1 @@ +export { LanguageServicesProvider } from "./LanguageServicesProvider"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/ResourceConsumption.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/ResourceConsumption.tsx index 46b7d39e635..0a834419eef 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/ResourceConsumption.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/ResourceConsumption.tsx @@ -179,9 +179,11 @@ export function ResourceConsumption() { > {normalizedSnapshot && ( - - {formatMemory(normalizedSnapshot.totalMemory)} - +
+ {formatCpu(normalizedSnapshot.totalCpu)} + / + {formatMemory(normalizedSnapshot.totalMemory)} +
)} @@ -193,7 +195,10 @@ export function ResourceConsumption() { showArrow={false} className="md:hidden" > - {formatMemory(normalizedSnapshot.totalMemory)} +
+ CPU {formatCpu(normalizedSnapshot.totalCpu)} + Memory {formatMemory(normalizedSnapshot.totalMemory)} +
)} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/PaneViewer/PaneViewer.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/PaneViewer/PaneViewer.tsx index ba1d4d82cca..caf19bfb4cb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/PaneViewer/PaneViewer.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/PaneViewer/PaneViewer.tsx @@ -10,10 +10,11 @@ import { } from "@superset/ui/dropdown-menu"; import { useNavigate } from "@tanstack/react-router"; import { FileCode2, Globe, MessageSquare, TerminalSquare } from "lucide-react"; -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { BsTerminalPlus } from "react-icons/bs"; import { TbMessageCirclePlus, TbWorld } from "react-icons/tb"; import { HotkeyMenuShortcut } from "renderer/components/HotkeyMenuShortcut"; +import { addBrowserShortcutListener } from "renderer/lib/browser-shortcut-events"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { WorkspaceChat } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat"; import { WorkspaceFilePreview } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/components/WorkspaceFilePreview/WorkspaceFilePreview"; @@ -211,6 +212,7 @@ export function PaneViewer({ return (