dsh-plugin-container 0.4.5 → 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/README.md +5 -3
- package/README.zh-CN.md +5 -3
- package/lib/client.js +1043 -682
- package/package.json +3 -2
- package/src/client/build-image.jsx +158 -55
- package/src/client/components.jsx +5 -0
- package/src/client/i18n.js +100 -22
- package/src/client/icons.jsx +12 -14
- package/src/client/index.jsx +15 -1
- package/src/client/page.jsx +73 -1
- package/src/client/platforms.js +2 -0
- package/src/client/push-image.jsx +113 -0
- package/src/client/styles.js +12 -1
- package/src/client/tabs/images.jsx +26 -20
- package/src/host/apple.js +13 -13
- package/src/host/images.js +140 -0
- package/src/host/service.js +26 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-container",
|
|
3
|
-
"version": "0.
|
|
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
|
|
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,
|
|
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 [
|
|
11
|
-
const [
|
|
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
|
-
|
|
20
|
-
|
|
41
|
+
setTagEdited(false)
|
|
42
|
+
setPlatform('')
|
|
21
43
|
setNoCache(false)
|
|
44
|
+
setBuildArgs('')
|
|
22
45
|
setError(null)
|
|
23
46
|
setBusy(false)
|
|
24
|
-
}, [open,
|
|
47
|
+
}, [open, defaultDir])
|
|
25
48
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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 (!
|
|
40
|
-
setError(t('
|
|
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:
|
|
50
|
-
|
|
51
|
-
|
|
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('
|
|
130
|
+
title={t('imgBuildTitle')}
|
|
66
131
|
subtitle={provider === 'apple-container' ? t('providerApple') : t('providerDocker')}
|
|
67
|
-
onClose={
|
|
132
|
+
onClose={onClose}
|
|
68
133
|
closeLabel={t('close')}
|
|
69
134
|
footer={
|
|
70
135
|
<>
|
|
71
|
-
<Button size="sm" variant="ghost" onClick={
|
|
72
|
-
<Button size="sm" variant="primary" onClick={submit} disabled={busy}>
|
|
73
|
-
{busy ? t('
|
|
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('
|
|
81
|
-
<
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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('
|
|
173
|
+
<span>{t('imgBuildTag')}</span>
|
|
92
174
|
<input
|
|
93
|
-
className="dsh-docker-input"
|
|
94
|
-
value={
|
|
95
|
-
onChange={(event) =>
|
|
96
|
-
|
|
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
|
-
<
|
|
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('
|
|
103
|
-
<
|
|
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
|
package/src/client/i18n.js
CHANGED
|
@@ -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
|
}
|
package/src/client/icons.jsx
CHANGED
|
@@ -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" />
|
package/src/client/index.jsx
CHANGED
|
@@ -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
|
)
|
package/src/client/page.jsx
CHANGED
|
@@ -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}
|