From c9372de69b2df8767459212cf8aa9a040a90f171 Mon Sep 17 00:00:00 2001 From: ymac <707337118@qq.com> Date: Sun, 16 Aug 2026 04:10:38 +0800 Subject: [PATCH 1/4] feat(editor): add C# (.cs) syntax highlighting Map the .cs extension to the built-in csharp StreamLanguage from @codemirror/legacy-modes/mode/clike (already a dependency, no new packages), so .cs files open with syntax highlighting in the sidebar editor. --- src/client/lang.ts | 3 +++ tests/unit.spec.ts | 1 + 2 files changed, 4 insertions(+) diff --git a/src/client/lang.ts b/src/client/lang.ts index e9460ca..c477232 100644 --- a/src/client/lang.ts +++ b/src/client/lang.ts @@ -23,6 +23,7 @@ import { toml } from '@codemirror/legacy-modes/mode/toml' import { nginx } from '@codemirror/legacy-modes/mode/nginx' import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile' import { properties } from '@codemirror/legacy-modes/mode/properties' +import { csharp } from '@codemirror/legacy-modes/mode/clike' /** The lowercased file extension of a path ('' when none). */ export function extOf(path: string): string { @@ -48,6 +49,7 @@ export function languageKeyForExt(ext: string): string | null { case 'yaml': case 'yml': return 'yaml' case 'sql': return 'sql' case 'java': return 'java' + case 'cs': return 'csharp' case 'c': case 'h': return 'c' case 'cc': case 'cpp': case 'cxx': case 'hpp': case 'hh': case 'hxx': return 'cpp' case 'rs': return 'rust' @@ -76,6 +78,7 @@ const FACTORIES: Record Language | LanguageSupport> = { yaml: () => yaml(), sql: () => sql(), java: () => java(), + csharp: () => StreamLanguage.define(csharp), c: () => cpp(), cpp: () => cpp(), rust: () => rust(), diff --git a/tests/unit.spec.ts b/tests/unit.spec.ts index 1cccf71..3512998 100644 --- a/tests/unit.spec.ts +++ b/tests/unit.spec.ts @@ -1102,6 +1102,7 @@ describe('editor language mapping', () => { expect(languageKeyForExt('yaml')).toBe('yaml') expect(languageKeyForExt('sh')).toBe('shell') expect(languageKeyForExt('md')).toBe('md') + expect(languageKeyForExt('cs')).toBe('csharp') expect(languageKeyForExt('txt')).toBeNull() expect(languageKeyForExt('log')).toBeNull() expect(languageKeyForExt('')).toBeNull() From d7dc60955366daaa2ff5ce6f87a54ab4f94b1597 Mon Sep 17 00:00:00 2001 From: ymac <707337118@qq.com> Date: Sun, 16 Aug 2026 04:11:30 +0800 Subject: [PATCH 2/4] feat(editor): add Kotlin (.kt/.kts) syntax highlighting Map .kt/.kts to the built-in kotlin StreamLanguage from @codemirror/legacy-modes/mode/clike (no new dependencies). --- src/client/lang.ts | 4 +++- tests/unit.spec.ts | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/client/lang.ts b/src/client/lang.ts index c477232..f7ac214 100644 --- a/src/client/lang.ts +++ b/src/client/lang.ts @@ -23,7 +23,7 @@ import { toml } from '@codemirror/legacy-modes/mode/toml' import { nginx } from '@codemirror/legacy-modes/mode/nginx' import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile' import { properties } from '@codemirror/legacy-modes/mode/properties' -import { csharp } from '@codemirror/legacy-modes/mode/clike' +import { csharp, kotlin } from '@codemirror/legacy-modes/mode/clike' /** The lowercased file extension of a path ('' when none). */ export function extOf(path: string): string { @@ -50,6 +50,7 @@ export function languageKeyForExt(ext: string): string | null { case 'sql': return 'sql' case 'java': return 'java' case 'cs': return 'csharp' + case 'kt': case 'kts': return 'kotlin' case 'c': case 'h': return 'c' case 'cc': case 'cpp': case 'cxx': case 'hpp': case 'hh': case 'hxx': return 'cpp' case 'rs': return 'rust' @@ -79,6 +80,7 @@ const FACTORIES: Record Language | LanguageSupport> = { sql: () => sql(), java: () => java(), csharp: () => StreamLanguage.define(csharp), + kotlin: () => StreamLanguage.define(kotlin), c: () => cpp(), cpp: () => cpp(), rust: () => rust(), diff --git a/tests/unit.spec.ts b/tests/unit.spec.ts index 3512998..d0292f2 100644 --- a/tests/unit.spec.ts +++ b/tests/unit.spec.ts @@ -1103,6 +1103,7 @@ describe('editor language mapping', () => { expect(languageKeyForExt('sh')).toBe('shell') expect(languageKeyForExt('md')).toBe('md') expect(languageKeyForExt('cs')).toBe('csharp') + expect(languageKeyForExt('kt')).toBe('kotlin') expect(languageKeyForExt('txt')).toBeNull() expect(languageKeyForExt('log')).toBeNull() expect(languageKeyForExt('')).toBeNull() From 515ed0abd05f762faf8f2d1ed8577a4806e7bea4 Mon Sep 17 00:00:00 2001 From: ymac <707337118@qq.com> Date: Sun, 16 Aug 2026 04:12:30 +0800 Subject: [PATCH 3/4] feat(editor): add Swift (.swift) syntax highlighting Map .swift to the built-in swift StreamLanguage from @codemirror/legacy-modes/mode/swift (no new dependencies). --- src/client/lang.ts | 3 +++ tests/unit.spec.ts | 1 + 2 files changed, 4 insertions(+) diff --git a/src/client/lang.ts b/src/client/lang.ts index f7ac214..8e7dc40 100644 --- a/src/client/lang.ts +++ b/src/client/lang.ts @@ -24,6 +24,7 @@ import { nginx } from '@codemirror/legacy-modes/mode/nginx' import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile' import { properties } from '@codemirror/legacy-modes/mode/properties' import { csharp, kotlin } from '@codemirror/legacy-modes/mode/clike' +import { swift } from '@codemirror/legacy-modes/mode/swift' /** The lowercased file extension of a path ('' when none). */ export function extOf(path: string): string { @@ -51,6 +52,7 @@ export function languageKeyForExt(ext: string): string | null { case 'java': return 'java' case 'cs': return 'csharp' case 'kt': case 'kts': return 'kotlin' + case 'swift': return 'swift' case 'c': case 'h': return 'c' case 'cc': case 'cpp': case 'cxx': case 'hpp': case 'hh': case 'hxx': return 'cpp' case 'rs': return 'rust' @@ -81,6 +83,7 @@ const FACTORIES: Record Language | LanguageSupport> = { java: () => java(), csharp: () => StreamLanguage.define(csharp), kotlin: () => StreamLanguage.define(kotlin), + swift: () => StreamLanguage.define(swift), c: () => cpp(), cpp: () => cpp(), rust: () => rust(), diff --git a/tests/unit.spec.ts b/tests/unit.spec.ts index d0292f2..7c3c0c6 100644 --- a/tests/unit.spec.ts +++ b/tests/unit.spec.ts @@ -1104,6 +1104,7 @@ describe('editor language mapping', () => { expect(languageKeyForExt('md')).toBe('md') expect(languageKeyForExt('cs')).toBe('csharp') expect(languageKeyForExt('kt')).toBe('kotlin') + expect(languageKeyForExt('swift')).toBe('swift') expect(languageKeyForExt('txt')).toBeNull() expect(languageKeyForExt('log')).toBeNull() expect(languageKeyForExt('')).toBeNull() From d33d6764fc7705bcdc1210eb19c6a4fad43df8cc Mon Sep 17 00:00:00 2001 From: ymac <707337118@qq.com> Date: Sun, 16 Aug 2026 13:45:18 +0800 Subject: [PATCH 4/4] test(smoke): fix flaky login-shell temp dir cleanup (ENOTEMPTY on CI) disposeAll() only requests the pty kill; the bash child exits asynchronously via onExit, so deleting the temp HOME right after races the still-running shell (it may be writing history files) and fails with ENOTEMPTY on CI (seen on both #114 and #120). Wait for the spawned handle to report exited (bounded 2s) before removing the directory, with a short retry loop as a fallback. --- tests/smoke.spec.ts | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index d92cb77..d5a1cf2 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -12,7 +12,7 @@ import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-setti import { apply, mediaTypeForPath } from '../src/index.ts' import * as git from '../src/git.ts' import { listDirectory } from '../src/fs-tree.ts' -import { defaultShell, PtyManager } from '../src/pty-manager.ts' +import { defaultShell, PtyManager, type SidebarPty } from '../src/pty-manager.ts' import type { SidebarWebRoute, SidebarWebUpgradeRoute } from '../src/context-types.ts' interface FakeContext { @@ -31,6 +31,32 @@ interface FakeContext { get: (key: string) => undefined } +/** + * The login-shell test spawns a real pty whose bash may still be writing to + * the temp HOME (history files, etc.) when `disposeAll()` returns — `close()` + * only requests the kill and the process exit lands asynchronously in + * `onExit`. Deleting the directory immediately then races the shell and + * fails with ENOTEMPTY on CI. Wait for the spawned handle to report `exited` + * (bounded), then remove with a short retry loop as a belt-and-braces + * fallback for any straggler fd. + */ +async function rmTempDirAfterPtyExit(handle: { exited: boolean }, dir: string): Promise { + const deadline = Date.now() + 2000 + while (Date.now() < deadline && !handle.exited) { + await new Promise((resolve) => setTimeout(resolve, 50)) + } + for (let attempt = 0; ; attempt++) { + try { + rmSync(dir, { recursive: true, force: true }) + return + } catch (error) { + const busy = (error as NodeJS.ErrnoException).code === 'ENOTEMPTY' || (error as NodeJS.ErrnoException).code === 'EBUSY' + if (!busy || attempt >= 4) throw error + await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1))) + } + } +} + describe('host plugin smoke', () => { it('serves PDF with the browser-native content type', () => { expect(mediaTypeForPath('/work/report.PDF')).toBe('application/pdf') @@ -191,6 +217,7 @@ describe('host plugin smoke', () => { it.skipIf(process.platform === 'win32')('spawns the shell as a login shell (loads ~/.profile)', async () => { const home = mkdtempSync(join(tmpdir(), 'dsh-sidebar-login-')) const previousHome = process.env.HOME + let handle: SidebarPty | undefined try { // A login bash reads ~/.profile (a non-login interactive bash reads // ~/.bashrc instead), so this marker proves the spawn used a login @@ -199,7 +226,7 @@ describe('host plugin smoke', () => { process.env.HOME = home const manager = new PtyManager('/bin/bash', 3) try { - const handle = manager.open('s5', 't1', process.cwd(), 80, 24) + handle = manager.open('s5', 't1', process.cwd(), 80, 24) handle.pty.write('echo $DSH_LOGIN_MARKER\r') const deadline = Date.now() + 5000 while (!handle.transcript.includes('loaded-from-profile') && Date.now() < deadline) { @@ -212,7 +239,7 @@ describe('host plugin smoke', () => { } finally { if (previousHome === undefined) delete process.env.HOME else process.env.HOME = previousHome - rmSync(home, { recursive: true, force: true }) + await rmTempDirAfterPtyExit(handle ?? { exited: true }, home) } })