dsh-vscode-workbench 0.1.11 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/host.ts CHANGED
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from 'node:crypto'
2
2
  import { spawn } from 'node:child_process'
3
3
  import { createRequire } from 'node:module'
4
4
  import { dirname, extname, isAbsolute, join, normalize, relative, resolve, sep } from 'node:path'
5
- import { access, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises'
5
+ import { access, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat } from 'node:fs/promises'
6
6
  import { constants } from 'node:fs'
7
7
  import type { HostContext } from './context.ts'
8
8
  import type { FileDocument, FileEntry, GitChange, GitCommit, GitCommitFile, GitDiff, GitStatus, SearchMatch, SearchRequest } from './types.ts'
@@ -83,14 +83,42 @@ export async function readDocument(cwd: string, path: string): Promise<FileDocum
83
83
  } finally { await handle.close() }
84
84
  }
85
85
 
86
+ const pendingSaves = new Map<string, Promise<void>>()
87
+
86
88
  export async function saveDocument(cwd: string, path: string, content: string, expected: string): Promise<FileDocument> {
87
- const { absolute } = await workspacePath(cwd, path)
88
- const current = await readDocument(cwd, path)
89
- if (current.revision !== expected) throw new WorkbenchError('revision-conflict', 'file changed on disk; reload before saving', 409)
90
- const temp = join(dirname(absolute), `.${randomUUID()}.dsh-vscode.tmp`)
91
- await writeFile(temp, content, { encoding: 'utf8', flag: 'wx' })
92
- try { await rename(temp, absolute) } catch (error) { await rm(temp, { force: true }); throw error }
93
- return readDocument(cwd, path)
89
+ const resolved = await workspacePath(cwd, path)
90
+ const absolute = await realpath(resolved.absolute)
91
+ const previous = pendingSaves.get(absolute) ?? Promise.resolve()
92
+ let release!: () => void
93
+ const pending = new Promise<void>(resolve => { release = resolve })
94
+ pendingSaves.set(absolute, pending)
95
+ await previous
96
+ let temp: string | undefined
97
+ try {
98
+ const checkRevision = async () => {
99
+ const checked = await workspacePath(cwd, path)
100
+ if (await realpath(checked.absolute) !== absolute || (await readDocument(cwd, path)).revision !== expected) {
101
+ throw new WorkbenchError('revision-conflict', 'file changed on disk; reload before saving', 409)
102
+ }
103
+ }
104
+ await checkRevision()
105
+ const info = await stat(absolute)
106
+ temp = join(dirname(absolute), `.${randomUUID()}.dsh-vscode.tmp`)
107
+ const handle = await open(temp, 'wx', info.mode & 0o777)
108
+ try {
109
+ await handle.writeFile(content, { encoding: 'utf8' })
110
+ await handle.chmod(info.mode & 0o777)
111
+ } finally { await handle.close() }
112
+ await checkRevision()
113
+ await rename(temp, absolute)
114
+ return await readDocument(cwd, path)
115
+ } finally {
116
+ try { if (temp !== undefined) await rm(temp, { force: true }) }
117
+ finally {
118
+ release()
119
+ if (pendingSaves.get(absolute) === pending) pendingSaves.delete(absolute)
120
+ }
121
+ }
94
122
  }
95
123
 
96
124
  export async function createEntry(cwd: string, path: string, kind: 'file' | 'directory'): Promise<void> {
@@ -120,8 +148,8 @@ export async function renameEntry(cwd: string, path: string, nextPath: string):
120
148
  }
121
149
 
122
150
  export async function deleteEntry(cwd: string, path: string): Promise<void> {
123
- if (path === '') throw new WorkbenchError('root-delete', 'cannot delete workspace root', 403)
124
- const { absolute } = await workspacePath(cwd, path)
151
+ const { root, absolute } = await workspacePath(cwd, path)
152
+ if (absolute === root) throw new WorkbenchError('root-delete', 'cannot delete workspace root', 403)
125
153
  await rm(absolute, { recursive: true, force: false })
126
154
  }
127
155
 
@@ -220,14 +248,19 @@ export async function gitStatus(cwd: string): Promise<GitStatus> {
220
248
  if (result.code !== 0) throw new WorkbenchError('git-failed', result.stderr, 500)
221
249
  let branch = 'HEAD'; let ahead = 0; let behind = 0
222
250
  const changes: GitChange[] = []
223
- for (const record of result.stdout.split('\0')) {
251
+ const records = result.stdout.split('\0')
252
+ for (let index = 0; index < records.length; index++) {
253
+ const record = records[index]!
224
254
  if (record.startsWith('# branch.head ')) branch = record.slice(14)
225
255
  else if (record.startsWith('# branch.ab ')) {
226
256
  const match = /\+(\d+) -(\d+)/.exec(record); ahead = Number(match?.[1] ?? 0); behind = Number(match?.[2] ?? 0)
227
257
  } else if (record.startsWith('? ')) changes.push({ path: record.slice(2), index: '?', worktree: '?', staged: false, untracked: true })
228
- else if (record.startsWith('1 ') || record.startsWith('2 ')) {
229
- const parts = record.split(' '); const xy = parts[1] ?? '..'; const path = parts.slice(record.startsWith('2 ') ? 9 : 8).join(' ')
230
- changes.push({ path, index: xy[0] ?? '.', worktree: xy[1] ?? '.', staged: xy[0] !== '.', untracked: false })
258
+ else if (record.startsWith('1 ') || record.startsWith('2 ') || record.startsWith('u ')) {
259
+ const renamed = record.startsWith('2 ')
260
+ const parts = record.split(' '); const xy = parts[1] ?? '..'
261
+ const path = parts.slice(renamed ? 9 : record.startsWith('u ') ? 10 : 8).join(' ')
262
+ const originalPath = renamed ? records[++index] : undefined
263
+ changes.push({ path, ...(originalPath === undefined ? {} : { originalPath }), index: xy[0] ?? '.', worktree: xy[1] ?? '.', staged: xy[0] !== '.', untracked: false })
231
264
  }
232
265
  }
233
266
  return { repository, branch, ahead, behind, changes }
package/src/terminal.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto'
2
- import { realpathSync } from 'node:fs'
3
- import { basename } from 'node:path'
2
+ import { chmodSync, realpathSync, statSync } from 'node:fs'
3
+ import { createRequire } from 'node:module'
4
+ import { basename, dirname, join } from 'node:path'
4
5
  import type { IncomingMessage } from 'node:http'
5
6
  import type { Duplex } from 'node:stream'
6
7
  import * as pty from 'node-pty'
@@ -10,6 +11,8 @@ import type { HostContext } from './context.ts'
10
11
  import { cwdFor } from './host.ts'
11
12
  import { authorize, readBody, send, sendError } from './wire.ts'
12
13
 
14
+ const require = createRequire(import.meta.url)
15
+
13
16
  export interface TerminalInfo { id: string; title: string; cwd: string; running: boolean; exitCode?: number; sequence: number }
14
17
  export interface TerminalOutput { sequence: number; data: string }
15
18
 
@@ -29,10 +32,32 @@ export function defaultTerminalShell(platform = process.platform, env = process.
29
32
  return { shell: env.SHELL || (platform === 'darwin' ? '/bin/zsh' : '/bin/bash'), args: ['-l'] }
30
33
  }
31
34
 
35
+ /** Add execute bits when a registry package extracted a native helper without them. */
36
+ export function ensureExecutable(path: string): boolean {
37
+ if (process.platform === 'win32') return true
38
+ try {
39
+ const mode = statSync(path).mode
40
+ if ((mode & 0o111) === 0) chmodSync(path, mode | 0o111)
41
+ return true
42
+ } catch {
43
+ return false
44
+ }
45
+ }
46
+
47
+ /** Repair node-pty's macOS/Linux helper, whose npm package currently loses its execute bit. */
48
+ export function repairNodePtyHelper(): void {
49
+ if (process.platform === 'win32') return
50
+ const packageDirectory = dirname(require.resolve('node-pty/package.json'))
51
+ ensureExecutable(join(packageDirectory, 'build', 'Release', 'spawn-helper'))
52
+ ensureExecutable(join(packageDirectory, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper'))
53
+ }
54
+
32
55
  /** Own interactive terminals by their canonical workspace directory. */
33
56
  export class TerminalManager {
34
57
  private readonly workspaces = new Map<string, Map<string, TerminalHandle>>()
35
58
 
59
+ constructor() { repairNodePtyHelper() }
60
+
36
61
  private workspace(cwd: string): string { return realpathSync(cwd) }
37
62
 
38
63
  list(cwd: string): TerminalInfo[] { return [...(this.workspaces.get(this.workspace(cwd))?.values() ?? [])].map(handle => ({ ...handle.info })) }