dsh-plugin-container 0.4.6 → 0.5.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-container",
3
- "version": "0.4.6",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "description": "DSH sidebar container panel for macOS Docker runtimes and Apple container",
6
6
  "type": "module",
@@ -41,7 +41,8 @@
41
41
  "prepack": "npm run build && npm test"
42
42
  },
43
43
  "devDependencies": {
44
- "esbuild": "0.25.0"
44
+ "esbuild": "0.25.0",
45
+ "@deepseek-ai/dsh-tools": "^0.2.1-rc.5"
45
46
  },
46
47
  "license": "MIT",
47
48
  "keywords": [
@@ -1,43 +1,106 @@
1
- /** Build a Docker/Apple image from a Dockerfile. */
2
- import { useEffect, useState } from 'react'
1
+ /** Build an image from a project directory, validating the directory as the user types. */
2
+ import { useEffect, useMemo, useRef, useState } from 'react'
3
3
 
4
- import { Button, Modal } from './components.jsx'
4
+ import { Button, Modal, fill } from './components.jsx'
5
+ import { PLATFORMS } from './platforms.js'
5
6
 
6
7
  const IMAGE_REF = /^[A-Za-z0-9][A-Za-z0-9_.:/@-]*$/u
7
8
 
8
- export default function BuildImageDialog({ open, t, provider, cwd, onClose, onSubmit }) {
9
+ export default function BuildImageDialog({ open, t, api, provider, defaultDir = '', pickDirectory, onClose, onSubmit }) {
10
+ const [dir, setDir] = useState('')
11
+ const [info, setInfo] = useState(null)
12
+ const [file, setFile] = useState('')
9
13
  const [tag, setTag] = useState('')
10
- const [contextPath, setContextPath] = useState('')
11
- const [dockerfile, setDockerfile] = useState('Dockerfile')
14
+ const [tagEdited, setTagEdited] = useState(false)
15
+ const [platform, setPlatform] = useState('')
12
16
  const [noCache, setNoCache] = useState(false)
17
+ const [buildArgs, setBuildArgs] = useState('')
13
18
  const [error, setError] = useState(null)
14
19
  const [busy, setBusy] = useState(false)
20
+ const requestId = useRef(0)
21
+ const canPick = typeof pickDirectory === 'function' && (pickDirectory.available?.() ?? true)
22
+
23
+ const choose = async () => {
24
+ try {
25
+ const picked = await pickDirectory()
26
+ if (typeof picked === 'string' && picked !== '') {
27
+ setDir(picked)
28
+ setError(null)
29
+ }
30
+ } catch (pickError) {
31
+ setError(pickError instanceof Error && pickError.message !== 'unsupported' ? pickError.message : t('imgBuildDirPickFailed'))
32
+ }
33
+ }
15
34
 
16
35
  useEffect(() => {
17
36
  if (!open) return
37
+ setDir(defaultDir)
38
+ setInfo(null)
39
+ setFile('')
18
40
  setTag('')
19
- setContextPath(cwd || '')
20
- setDockerfile('Dockerfile')
41
+ setTagEdited(false)
42
+ setPlatform('')
21
43
  setNoCache(false)
44
+ setBuildArgs('')
22
45
  setError(null)
23
46
  setBusy(false)
24
- }, [open, cwd])
47
+ }, [open, defaultDir])
25
48
 
26
- const close = () => {
27
- if (busy) return
28
- onClose()
29
- }
49
+ // Validate the directory shortly after the user stops typing.
50
+ useEffect(() => {
51
+ if (!open) return undefined
52
+ const path = dir.trim()
53
+ if (path === '') {
54
+ setInfo(null)
55
+ return undefined
56
+ }
57
+ const id = ++requestId.current
58
+ const timer = setTimeout(async () => {
59
+ try {
60
+ const value = await api.call('buildContext', { path })
61
+ if (id !== requestId.current) return
62
+ setInfo(value)
63
+ setFile((current) => (value.dockerfiles.includes(current) ? current : (value.dockerfiles[0] ?? '')))
64
+ if (value.suggestedTag !== '') setTag((current) => (tagEdited || current !== '' ? current : value.suggestedTag))
65
+ } catch {
66
+ if (id === requestId.current) setInfo(null)
67
+ }
68
+ }, 300)
69
+ return () => clearTimeout(timer)
70
+ }, [api, dir, open, tagEdited])
71
+
72
+ const args = useMemo(() => buildArgs.split(/\n/u).map((line) => line.trim()).filter(Boolean), [buildArgs])
73
+ const tagValid = IMAGE_REF.test(tag.trim())
74
+ const dirOk = info !== null && info.isDirectory && info.dockerfiles.length > 0
75
+ const trimmedDir = dir.trim()
76
+ const dirChecked = info !== null && info.path === trimmedDir
77
+
78
+ let dirLine = null
79
+ if (trimmedDir !== '' && !dirChecked) dirLine = { tone: 'hint', text: t('imgBuildDirChecking') }
80
+ else if (dirChecked && !info.exists) dirLine = { tone: 'err', text: t('imgBuildDirMissing') }
81
+ else if (dirChecked && !info.isDirectory) dirLine = { tone: 'err', text: t('imgBuildDirNotDir') }
82
+ else if (dirChecked && info.dockerfiles.length === 0) dirLine = { tone: 'err', text: t('imgBuildDirNoFile') }
83
+ else if (dirChecked) dirLine = { tone: 'ok', text: fill(t('imgBuildDirOk'), { count: info.dockerfiles.length }) }
84
+
85
+ const command = useMemo(() => {
86
+ const cli = provider === 'apple-container' ? 'container' : 'docker'
87
+ const parts = [cli, 'build', '-t', tag.trim() || '<name:tag>']
88
+ if (file !== '' && file !== 'Dockerfile') parts.push('-f', file)
89
+ if (platform !== '') parts.push('--platform', platform)
90
+ if (noCache) parts.push('--no-cache')
91
+ for (const arg of args) parts.push('--build-arg', arg)
92
+ parts.push('.')
93
+ return `cd ${trimmedDir || '<dir>'}\n${parts.join(' ')}`
94
+ }, [args, file, noCache, platform, provider, tag, trimmedDir])
30
95
 
31
96
  const submit = async (event) => {
32
97
  event?.preventDefault()
33
- const imageTag = tag.trim()
34
- const context = contextPath.trim()
35
- if (!IMAGE_REF.test(imageTag)) {
36
- setError(t('buildInvalidTag'))
98
+ if (!dirOk) {
99
+ setError(dirLine?.tone === 'err' ? dirLine.text : t('imgBuildDirPlaceholder'))
37
100
  return
38
101
  }
39
- if (!context.startsWith('/')) {
40
- setError(t('buildInvalidContext'))
102
+ if (!tagValid) {
103
+ setError(t('imgBuildTagRequired'))
41
104
  return
42
105
  }
43
106
  setBusy(true)
@@ -46,12 +109,14 @@ export default function BuildImageDialog({ open, t, provider, cwd, onClose, onSu
46
109
  await onSubmit({
47
110
  kind: 'image',
48
111
  action: 'build',
49
- tag: imageTag,
50
- contextPath: context,
51
- dockerfile: dockerfile.trim() === '' ? undefined : dockerfile.trim(),
112
+ tag: tag.trim(),
113
+ name: tag.trim(),
114
+ contextDir: trimmedDir,
115
+ file: file === '' ? undefined : file,
116
+ platform: platform === '' ? undefined : platform,
52
117
  noCache,
118
+ buildArgs: args,
53
119
  })
54
- setBusy(false)
55
120
  onClose()
56
121
  } catch (submitError) {
57
122
  setError(submitError instanceof Error ? submitError.message : String(submitError))
@@ -62,56 +127,94 @@ export default function BuildImageDialog({ open, t, provider, cwd, onClose, onSu
62
127
  return (
63
128
  <Modal
64
129
  open={open}
65
- title={t('buildImageTitle')}
130
+ title={t('imgBuildTitle')}
66
131
  subtitle={provider === 'apple-container' ? t('providerApple') : t('providerDocker')}
67
- onClose={close}
132
+ onClose={onClose}
68
133
  closeLabel={t('close')}
69
134
  footer={
70
135
  <>
71
- <Button size="sm" variant="ghost" onClick={close} disabled={busy}>{t('modalCancel')}</Button>
72
- <Button size="sm" variant="primary" onClick={submit} disabled={busy}>
73
- {busy ? t('starting') : t('buildStart')}
136
+ <Button size="sm" variant="ghost" onClick={onClose} disabled={busy}>{t('modalCancel')}</Button>
137
+ <Button size="sm" variant="primary" onClick={submit} disabled={busy || !dirOk || !tagValid}>
138
+ {busy ? t('imgBuilding') : t('imgBuildStart')}
74
139
  </Button>
75
140
  </>
76
141
  }
77
142
  >
78
143
  <form className="dsh-docker-run-form" onSubmit={submit}>
79
144
  <label className="dsh-docker-field">
80
- <span>{t('buildTag')}</span>
81
- <input
82
- className="dsh-docker-input"
83
- value={tag}
84
- onChange={(event) => setTag(event.target.value)}
85
- placeholder="my-app:latest"
86
- spellCheck="false"
87
- autoFocus
88
- />
145
+ <span>{t('imgBuildDir')}</span>
146
+ <div className="dsh-docker-image-picker">
147
+ <input
148
+ className="dsh-docker-input dsh-docker-mono"
149
+ style={{ flex: 1, minWidth: 0 }}
150
+ value={dir}
151
+ onChange={(event) => setDir(event.target.value)}
152
+ onClick={canPick ? choose : undefined}
153
+ readOnly={canPick}
154
+ placeholder={canPick ? t('imgBuildDirChoose') : t('imgBuildDirPlaceholder')}
155
+ spellCheck="false"
156
+ autoFocus={!canPick}
157
+ />
158
+ {canPick && <Button size="sm" onClick={choose}>{dir.trim() === '' ? t('imgBuildDirChoose') : t('imgBuildDirChange')}</Button>}
159
+ </div>
160
+ {dirLine === null || dirLine.tone === 'hint'
161
+ ? <span className="dsh-docker-hint">{dirLine?.text ?? t('imgBuildDirHint')}</span>
162
+ : <span className={dirLine.tone === 'ok' ? 'dsh-docker-line-ok' : 'dsh-docker-line-err'}>{dirLine.text}</span>}
89
163
  </label>
164
+ {dirOk && info.dockerfiles.length > 1 && (
165
+ <label className="dsh-docker-field">
166
+ <span>{t('imgBuildFile')}</span>
167
+ <select className="dsh-docker-select" value={file} onChange={(event) => setFile(event.target.value)}>
168
+ {info.dockerfiles.map((name) => <option key={name} value={name}>{name}</option>)}
169
+ </select>
170
+ </label>
171
+ )}
90
172
  <label className="dsh-docker-field">
91
- <span>{t('buildContext')}</span>
173
+ <span>{t('imgBuildTag')}</span>
92
174
  <input
93
- className="dsh-docker-input"
94
- value={contextPath}
95
- onChange={(event) => setContextPath(event.target.value)}
96
- placeholder="/Users/me/project"
175
+ className="dsh-docker-input dsh-docker-mono"
176
+ value={tag}
177
+ onChange={(event) => {
178
+ setTag(event.target.value)
179
+ setTagEdited(true)
180
+ }}
181
+ placeholder={t('imgBuildTagPlaceholder')}
97
182
  spellCheck="false"
98
183
  />
99
- <small className="dsh-docker-muted">{t('buildContextHint')}</small>
184
+ <span className="dsh-docker-hint">{t('imgBuildTagHint')}</span>
100
185
  </label>
186
+ <details className="dsh-docker-advanced">
187
+ <summary>{t('imgBuildAdvanced')}</summary>
188
+ <div className="dsh-docker-advanced-body">
189
+ <label className="dsh-docker-field">
190
+ <span>{t('imgBuildPlatform')}</span>
191
+ <select className="dsh-docker-select" value={platform} onChange={(event) => setPlatform(event.target.value)}>
192
+ <option value="">{t('imgBuildPlatformAuto')}</option>
193
+ {PLATFORMS.map((name) => <option key={name} value={name}>{name}</option>)}
194
+ </select>
195
+ </label>
196
+ <label className="dsh-docker-check">
197
+ <input type="checkbox" checked={noCache} onChange={(event) => setNoCache(event.target.checked)} />
198
+ <span>{t('imgBuildNoCache')}</span>
199
+ </label>
200
+ <label className="dsh-docker-field">
201
+ <span>{t('imgBuildArgs')}</span>
202
+ <textarea
203
+ className="dsh-docker-input dsh-docker-textarea"
204
+ value={buildArgs}
205
+ onChange={(event) => setBuildArgs(event.target.value)}
206
+ placeholder={t('imgBuildArgsPlaceholder')}
207
+ spellCheck="false"
208
+ rows={3}
209
+ />
210
+ </label>
211
+ </div>
212
+ </details>
101
213
  <label className="dsh-docker-field">
102
- <span>{t('buildDockerfile')}</span>
103
- <input
104
- className="dsh-docker-input"
105
- value={dockerfile}
106
- onChange={(event) => setDockerfile(event.target.value)}
107
- placeholder="Dockerfile"
108
- spellCheck="false"
109
- />
110
- </label>
111
- <label className="dsh-docker-check">
112
- <input type="checkbox" checked={noCache} onChange={(event) => setNoCache(event.target.checked)} />
113
- {t('buildNoCache')}
214
+ <span>{t('imgCommandPreview')}</span>
215
+ <pre className="dsh-docker-preview">{command}</pre>
114
216
  </label>
217
+ <div className="dsh-docker-hint">{t('imgBuildHint')}</div>
115
218
  {error !== null && <div className="dsh-docker-form-error">{error}</div>}
116
219
  </form>
117
220
  </Modal>
@@ -313,6 +313,11 @@ export function displayValue(value) {
313
313
  }
314
314
 
315
315
  /** @param {unknown} error */
316
+ /** Replace `{key}` placeholders in a localized string. */
317
+ export function fill(text, values = {}) {
318
+ return String(text).replace(/\{(\w+)\}/gu, (match, key) => (values[key] === undefined ? match : String(values[key])))
319
+ }
320
+
316
321
  export function errorMessage(error) {
317
322
  if (error === undefined || error === null) return ''
318
323
  if (error instanceof Error) return error.message
@@ -117,17 +117,6 @@ export const zh = {
117
117
  removeNetwork: '删除网络',
118
118
  pull: '拉取',
119
119
  pullPlaceholder: 'nginx:latest',
120
- pushImage: '推送镜像',
121
- buildImage: '构建镜像',
122
- buildImageTitle: '构建镜像',
123
- buildTag: '镜像标签',
124
- buildContext: '构建上下文(绝对路径)',
125
- buildContextHint: '例如 /Users/me/project,必须包含 Dockerfile。',
126
- buildDockerfile: 'Dockerfile',
127
- buildNoCache: '不使用构建缓存',
128
- buildStart: '开始构建',
129
- buildInvalidTag: '请输入合法的镜像标签,例如 my-app:latest',
130
- buildInvalidContext: '构建上下文必须是绝对路径',
131
120
  pullAction: '拉取镜像',
132
121
  emptyContainers: '没有容器',
133
122
  emptyImages: '没有镜像',
@@ -264,6 +253,56 @@ export const zh = {
264
253
  restartToApply: '需要重启 DSH 才能应用 host 变更',
265
254
  notInstalled: '未安装',
266
255
  unknown: '未知',
256
+ imgPush: '推送',
257
+ imgPushTitle: '推送镜像',
258
+ imgPushSource: '本地镜像',
259
+ imgPushTarget: '推送到',
260
+ imgPushTargetHint: '格式:[仓库地址/]用户名/镜像名:标签。推送到 Docker Hub 必须带用户名前缀。',
261
+ imgPushNeedsNamespace: '这个名字没有用户名前缀,推送到 Docker Hub 通常会被拒绝。例如:myname/{name}',
262
+ imgPushRetag: '目标名和本地名不同,会先打标签(tag)再推送;本地镜像不会被改名。',
263
+ imgPushLoginDocker: '推送前需要先登录仓库:在终端运行 docker login(私有仓库加上地址,例如 docker login ghcr.io)。',
264
+ imgPushLoginApple: '推送前需要先登录仓库:在终端运行 container registry login <仓库地址>。',
265
+ imgPushPresets: '常用仓库',
266
+ imgPushStart: '开始推送',
267
+ imgPushing: '推送中…',
268
+ imgPushDone: '镜像已推送:{name}',
269
+ imgPushFailed: '推送失败,详情见控制台',
270
+ imgPushAuthFailed: '推送被拒绝:请先登录仓库,并确认镜像名带有你的用户名前缀。',
271
+ imgCommandPreview: '将执行',
272
+ imgBuild: '构建镜像',
273
+ imgBuildTitle: '构建镜像',
274
+ imgBuildDir: '构建目录',
275
+ imgBuildDirPlaceholder: '/Users/你/项目目录(包含 Dockerfile)',
276
+ imgBuildDirHint: '选择包含 Dockerfile 的项目文件夹,默认使用控制台的工作目录。',
277
+ imgBuildDirChecking: '正在检查目录…',
278
+ imgBuildDirMissing: '目录不存在,请检查路径。',
279
+ imgBuildDirNotDir: '这不是一个目录。',
280
+ imgBuildDirNoFile: '目录里没有 Dockerfile 或 Containerfile。',
281
+ imgBuildDirOk: '找到 {count} 个构建文件',
282
+ imgBuildFile: '构建文件',
283
+ imgBuildTag: '镜像名:标签',
284
+ imgBuildTagPlaceholder: 'myapp:latest',
285
+ imgBuildTagHint: '仓库部分必须小写。要推送到 Docker Hub,请写成 用户名/镜像名:标签。',
286
+ imgBuildTagRequired: '请填写镜像名,例如 myapp:latest',
287
+ imgBuildAdvanced: '高级选项',
288
+ imgBuildPlatform: '目标平台',
289
+ imgBuildPlatformAuto: '默认(当前架构)',
290
+ imgBuildNoCache: '不使用缓存(--no-cache)',
291
+ imgBuildArgs: '构建参数(每行一个)',
292
+ imgBuildArgsPlaceholder: 'KEY=VALUE',
293
+ imgBuildStart: '开始构建',
294
+ imgBuilding: '构建中…',
295
+ imgBuildDone: '镜像构建完成:{name}',
296
+ imgBuildFailed: '构建失败,详情见控制台',
297
+ imgBuildHint: '构建可能需要几分钟,进度在“控制台”标签查看。',
298
+ imgPullDone: '镜像已拉取:{name}',
299
+ imgPullFailed: '拉取失败,详情见控制台',
300
+ imgPullHint: '输入镜像名,例如 nginx:latest;没写标签时默认 latest。',
301
+ imgJobBusy: '已有命令正在运行,请等它结束或先取消。',
302
+ imgPullPlatform: '镜像架构',
303
+ imgBuildDirChoose: '选择文件夹…',
304
+ imgBuildDirChange: '更换',
305
+ imgBuildDirPickFailed: '无法打开文件夹选择器,请重试。',
267
306
  }
268
307
 
269
308
  export const en = {
@@ -374,17 +413,6 @@ export const en = {
374
413
  removeNetwork: 'Remove network',
375
414
  pull: 'Pull',
376
415
  pullPlaceholder: 'nginx:latest',
377
- pushImage: 'Push image',
378
- buildImage: 'Build image',
379
- buildImageTitle: 'Build image',
380
- buildTag: 'Image tag',
381
- buildContext: 'Build context (absolute path)',
382
- buildContextHint: 'For example /Users/me/project; it must contain the Dockerfile.',
383
- buildDockerfile: 'Dockerfile',
384
- buildNoCache: 'Disable build cache',
385
- buildStart: 'Build',
386
- buildInvalidTag: 'Enter a valid image tag, for example my-app:latest',
387
- buildInvalidContext: 'Build context must be an absolute path',
388
416
  pullAction: 'Pull image',
389
417
  emptyContainers: 'No containers',
390
418
  emptyImages: 'No images',
@@ -521,4 +549,54 @@ export const en = {
521
549
  restartToApply: 'Restart DSH to apply host-side changes',
522
550
  notInstalled: 'Not installed',
523
551
  unknown: 'Unknown',
552
+ imgPush: 'Push',
553
+ imgPushTitle: 'Push image',
554
+ imgPushSource: 'Local image',
555
+ imgPushTarget: 'Push to',
556
+ imgPushTargetHint: 'Format: [registry/]username/image:tag. Docker Hub requires a username prefix.',
557
+ imgPushNeedsNamespace: 'This name has no username prefix, so Docker Hub will usually reject the push. For example: myname/{name}',
558
+ imgPushRetag: 'The target differs from the local name, so the image is tagged first and then pushed. The local image is not renamed.',
559
+ imgPushLoginDocker: 'Log in to the registry first: run docker login in a terminal (add the address for private registries, e.g. docker login ghcr.io).',
560
+ imgPushLoginApple: 'Log in to the registry first: run container registry login <registry> in a terminal.',
561
+ imgPushPresets: 'Common registries',
562
+ imgPushStart: 'Start push',
563
+ imgPushing: 'Pushing…',
564
+ imgPushDone: 'Image pushed: {name}',
565
+ imgPushFailed: 'Push failed, see the console for details',
566
+ imgPushAuthFailed: 'Push was denied: log in to the registry first and make sure the image name has your username prefix.',
567
+ imgCommandPreview: 'Will run',
568
+ imgBuild: 'Build image',
569
+ imgBuildTitle: 'Build image',
570
+ imgBuildDir: 'Build directory',
571
+ imgBuildDirPlaceholder: '/Users/you/project (contains a Dockerfile)',
572
+ imgBuildDirHint: 'Choose the project folder that contains a Dockerfile. Defaults to the console working directory.',
573
+ imgBuildDirChecking: 'Checking directory…',
574
+ imgBuildDirMissing: 'Directory not found. Check the path.',
575
+ imgBuildDirNotDir: 'This is not a directory.',
576
+ imgBuildDirNoFile: 'No Dockerfile or Containerfile in this directory.',
577
+ imgBuildDirOk: 'Found {count} build file(s)',
578
+ imgBuildFile: 'Build file',
579
+ imgBuildTag: 'Image name:tag',
580
+ imgBuildTagPlaceholder: 'myapp:latest',
581
+ imgBuildTagHint: 'The repository part must be lowercase. To push to Docker Hub, use username/image:tag.',
582
+ imgBuildTagRequired: 'Enter an image name, for example myapp:latest',
583
+ imgBuildAdvanced: 'Advanced options',
584
+ imgBuildPlatform: 'Target platform',
585
+ imgBuildPlatformAuto: 'Default (this machine)',
586
+ imgBuildNoCache: 'Do not use cache (--no-cache)',
587
+ imgBuildArgs: 'Build arguments (one per line)',
588
+ imgBuildArgsPlaceholder: 'KEY=VALUE',
589
+ imgBuildStart: 'Start build',
590
+ imgBuilding: 'Building…',
591
+ imgBuildDone: 'Image built: {name}',
592
+ imgBuildFailed: 'Build failed, see the console for details',
593
+ imgBuildHint: 'A build can take a few minutes. Follow the progress in the Console tab.',
594
+ imgPullDone: 'Image pulled: {name}',
595
+ imgPullFailed: 'Pull failed, see the console for details',
596
+ imgPullHint: 'Enter an image name such as nginx:latest. Without a tag, latest is used.',
597
+ imgJobBusy: 'Another command is running. Wait for it to finish or cancel it first.',
598
+ imgPullPlatform: 'Image architecture',
599
+ imgBuildDirChoose: 'Choose folder…',
600
+ imgBuildDirChange: 'Change',
601
+ imgBuildDirPickFailed: 'Could not open the folder picker. Try again.',
524
602
  }
@@ -191,6 +191,18 @@ export const IconDownload = (props) => (
191
191
  </Svg>
192
192
  )
193
193
 
194
+ export const IconUpload = (props) => (
195
+ <Svg {...props}>
196
+ <path d="M12 15.5V5.3M7.8 9.4 12 5.2l4.2 4.2M5 18.8h14" />
197
+ </Svg>
198
+ )
199
+
200
+ export const IconHammer = (props) => (
201
+ <Svg {...props}>
202
+ <path d="m13.2 8.4 2.4 2.4M10.6 11 4.8 16.8a1.7 1.7 0 0 0 2.4 2.4L13 13.4M12.4 5.6l3-3 5.6 5.6-3 3z" />
203
+ </Svg>
204
+ )
205
+
194
206
  export const IconExternal = (props) => (
195
207
  <Svg {...props}>
196
208
  <path d="M13.5 5H19v5.5M19 5l-7.4 7.4" />
@@ -214,20 +226,6 @@ export const IconExec = (props) => (
214
226
  </Svg>
215
227
  )
216
228
 
217
- export const IconUpload = (props) => (
218
- <Svg {...props}>
219
- <path d="M12 16V4M7.5 8.5 12 4l4.5 4.5" />
220
- <path d="M5 15v3.5A1.5 1.5 0 0 0 6.5 20h11a1.5 1.5 0 0 0 1.5-1.5V15" />
221
- </Svg>
222
- )
223
-
224
- export const IconBuild = (props) => (
225
- <Svg {...props}>
226
- <path d="M14.5 5.5 18.5 9.5M4 20l4.2-1 10.9-10.9a2.1 2.1 0 0 0-3-3L5.2 16 4 20Z" />
227
- <path d="m13.2 6.8 4 4" />
228
- </Svg>
229
- )
230
-
231
229
  export const IconAttach = (props) => (
232
230
  <Svg {...props}>
233
231
  <path d="M9.5 14.5 14.5 9.5" />
@@ -38,6 +38,20 @@ export function apply(ctx) {
38
38
  return sessionId
39
39
  }
40
40
 
41
+ // Desktop exposes the OS chooser directly; other peers go through the workspace UI.
42
+ const pickDirectory = async () => {
43
+ const desktop = globalThis.__DSH_DIRECTORY_PICKER__
44
+ if (desktop !== undefined && typeof desktop.pick === 'function') return desktop.pick()
45
+ const workspace = ctx.get('uiWorkspace')
46
+ if (typeof workspace?.pickDirectory === 'function') return workspace.pickDirectory()
47
+ throw new Error('unsupported')
48
+ }
49
+ const canPickDirectory = () => (
50
+ (globalThis.__DSH_DIRECTORY_PICKER__ !== undefined && typeof globalThis.__DSH_DIRECTORY_PICKER__.pick === 'function') ||
51
+ typeof ctx.get('uiWorkspace')?.pickDirectory === 'function'
52
+ )
53
+ pickDirectory.available = canPickDirectory
54
+
41
55
  const handle = createDockerStore()
42
56
  const instance = handle.create()
43
57
  const store = { ...handle, create: () => instance }
@@ -51,7 +65,7 @@ export function apply(ctx) {
51
65
  key: 'docker',
52
66
  locale: NS,
53
67
  store,
54
- inject: () => ({ api, runs, startDiagnosis }),
68
+ inject: () => ({ api, runs, startDiagnosis, pickDirectory }),
55
69
  },
56
70
  DockerPage,
57
71
  )
@@ -2,6 +2,8 @@
2
2
  import { Component, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
3
3
 
4
4
  import AttachDialog from './attach.jsx'
5
+ import BuildImageDialog from './build-image.jsx'
6
+ import PushImageDialog from './push-image.jsx'
5
7
  import RunContainerDialog from './run-container.jsx'
6
8
  import { cacheClear, DockerApiError } from './api.js'
7
9
  import {
@@ -16,6 +18,7 @@ import {
16
18
  ToastStack,
17
19
  cx,
18
20
  errorMessage,
21
+ fill,
19
22
  formatTime,
20
23
  } from './components.jsx'
21
24
  import { useDockerStatus, useProviders } from './hooks.js'
@@ -85,7 +88,7 @@ function tabCount(tab, status, counts) {
85
88
  }
86
89
 
87
90
  export default function DockerPage(props) {
88
- const { t, api, runs, useStore, actions, startDiagnosis } = props
91
+ const { t, api, runs, useStore, actions, startDiagnosis, pickDirectory } = props
89
92
  const store = useStore((snapshot) => snapshot)
90
93
  const tab = store.tab
91
94
  const provider = store.provider
@@ -99,6 +102,9 @@ export default function DockerPage(props) {
99
102
  const [confirm, setConfirm] = useState(null)
100
103
  const [attach, setAttach] = useState({ open: false, loading: false, mode: 'attach', session: null, output: '', error: null })
101
104
  const [runDialog, setRunDialog] = useState({ open: false, image: '' })
105
+ const [pushDialog, setPushDialog] = useState({ open: false, image: '' })
106
+ const [buildDialog, setBuildDialog] = useState(false)
107
+ const watchedJob = useRef(null)
102
108
  const attachRef = useRef(null)
103
109
  const attachRequest = useRef(0)
104
110
  const [toasts, setToasts] = useState([])
@@ -464,6 +470,49 @@ export default function DockerPage(props) {
464
470
  }
465
471
  }, [actions, confirmThen, notify, provider, runs, scheduleListRefresh, t])
466
472
 
473
+ const openPush = useCallback((image) => setPushDialog({ open: true, image }), [])
474
+ const closePush = useCallback(() => setPushDialog({ open: false, image: '' }), [])
475
+ const openBuild = useCallback(() => setBuildDialog(true), [])
476
+ const closeBuild = useCallback(() => setBuildDialog(false), [])
477
+
478
+ /** Start pull/push/build; throws so dialogs can show the error inline. */
479
+ const startImageJob = useCallback(async ({ name, ...input }) => {
480
+ if (runs.active) throw new Error(t('imgJobBusy'))
481
+ const run = await runs.startAction({ provider, context: provider === 'docker' ? selectedContext : undefined, ...input })
482
+ if (run !== null) {
483
+ watchedJob.current = { id: run.id, action: input.action, name: name ?? input.id ?? input.tag }
484
+ actions.setTab('console')
485
+ scheduleListRefresh()
486
+ }
487
+ return run
488
+ }, [actions, provider, runs, scheduleListRefresh, selectedContext, t])
489
+
490
+ const pullImage = useCallback(async (reference, platform) => {
491
+ try {
492
+ await startImageJob({ kind: 'image', action: 'pull', id: reference, platform, name: platform === undefined ? reference : `${reference} (${platform})` })
493
+ } catch (error) {
494
+ notify('error', errorMessage(error))
495
+ }
496
+ }, [notify, startImageJob])
497
+
498
+ // Tell the user how a pull/push/build ended, with a login hint for rejected pushes.
499
+ useEffect(() => {
500
+ const watched = watchedJob.current
501
+ const run = runState.run
502
+ if (watched === null || run === null || run === undefined || run.id !== watched.id) return
503
+ if (run.status === 'running' || run.status === 'cancelling') return
504
+ watchedJob.current = null
505
+ if (run.status === 'cancelled') return
506
+ const key = watched.action === 'push' ? 'imgPush' : watched.action === 'build' ? 'imgBuild' : 'imgPull'
507
+ if (run.status === 'exited') {
508
+ notify('success', fill(t(`${key}Done`), { name: watched.name }))
509
+ } else if (key === 'imgPush' && /denied|unauthorized|authentication required|access forbidden/iu.test(`${runState.stderr}${runState.stdout}`)) {
510
+ notify('error', t('imgPushAuthFailed'))
511
+ } else {
512
+ notify('error', t(`${key}Failed`))
513
+ }
514
+ }, [notify, runState.run, runState.stderr, runState.stdout, t])
515
+
467
516
  const openTab = useCallback((next) => actions.setTab(next), [actions])
468
517
 
469
518
  const tabProps = {
@@ -479,6 +528,9 @@ export default function DockerPage(props) {
479
528
  onAction: runAction,
480
529
  onAttach: openAttach,
481
530
  onRunContainer: openRun,
531
+ onPullImage: pullImage,
532
+ onPushImage: openPush,
533
+ onBuildImage: openBuild,
482
534
  onAskAI: askAI,
483
535
  canAskAI,
484
536
  onRuntime: runRuntime,
@@ -655,6 +707,26 @@ export default function DockerPage(props) {
655
707
  onSubmit={async (payload) => { await runAction(payload) }}
656
708
  />
657
709
 
710
+ <PushImageDialog
711
+ open={pushDialog.open}
712
+ t={t}
713
+ provider={provider}
714
+ image={pushDialog.image}
715
+ onClose={closePush}
716
+ onSubmit={startImageJob}
717
+ />
718
+
719
+ <BuildImageDialog
720
+ open={buildDialog}
721
+ t={t}
722
+ api={api}
723
+ provider={provider}
724
+ defaultDir={cwd}
725
+ pickDirectory={pickDirectory}
726
+ onClose={closeBuild}
727
+ onSubmit={startImageJob}
728
+ />
729
+
658
730
  <AttachDialog
659
731
  open={attach.open}
660
732
  mode={attach.mode}
@@ -0,0 +1,2 @@
1
+ /** Image platforms offered in the pull and build forms. */
2
+ export const PLATFORMS = ['linux/amd64', 'linux/arm64']