dsh-plugin-container 0.0.0-stage → 0.3.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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -2
  3. package/cordis.patch.yml +3 -0
  4. package/docs/ai-chat.png +0 -0
  5. package/docs/ai-new-session.png +0 -0
  6. package/docs/apple-container.png +0 -0
  7. package/docs/attach.png +0 -0
  8. package/docs/docker-console.png +0 -0
  9. package/docs/docker-overview.png +0 -0
  10. package/docs/docker-volumes.png +0 -0
  11. package/docs/image-architecture.png +0 -0
  12. package/docs/package-install.png +0 -0
  13. package/docs/provider-switch.png +0 -0
  14. package/docs/run-container.png +0 -0
  15. package/index.js +138 -0
  16. package/lib/client.js +3560 -0
  17. package/package.json +62 -4
  18. package/src/client/api.js +98 -0
  19. package/src/client/attach.jsx +134 -0
  20. package/src/client/components.jsx +320 -0
  21. package/src/client/hooks.js +161 -0
  22. package/src/client/i18n.js +502 -0
  23. package/src/client/icons.jsx +227 -0
  24. package/src/client/index.jsx +76 -0
  25. package/src/client/page.jsx +782 -0
  26. package/src/client/run-container.jsx +187 -0
  27. package/src/client/runs.js +182 -0
  28. package/src/client/store.js +91 -0
  29. package/src/client/styles.js +371 -0
  30. package/src/client/tabs/compose.jsx +94 -0
  31. package/src/client/tabs/console.jsx +211 -0
  32. package/src/client/tabs/containers.jsx +148 -0
  33. package/src/client/tabs/images.jsx +127 -0
  34. package/src/client/tabs/networks.jsx +91 -0
  35. package/src/client/tabs/overview.jsx +266 -0
  36. package/src/client/tabs/volumes.jsx +76 -0
  37. package/src/host/apple-cli.js +307 -0
  38. package/src/host/apple.js +471 -0
  39. package/src/host/attach.js +246 -0
  40. package/src/host/commands.js +235 -0
  41. package/src/host/docker-cli.js +405 -0
  42. package/src/host/jobs.js +245 -0
  43. package/src/host/parsers.js +484 -0
  44. package/src/host/runtime.js +285 -0
  45. package/src/host/service.js +836 -0
  46. package/src/host/tools.js +198 -0
  47. package/src/host/util.js +97 -0
@@ -0,0 +1,187 @@
1
+ /** Start a new container from a local image or a manually entered reference. */
2
+ import { useEffect, useMemo, useState } from 'react'
3
+
4
+ import { Button, Modal } from './components.jsx'
5
+ import { useDockerList } from './hooks.js'
6
+
7
+ export default function RunContainerDialog({
8
+ open,
9
+ t,
10
+ api,
11
+ provider,
12
+ context,
13
+ status,
14
+ refreshToken = 0,
15
+ features = [],
16
+ initialImage = '',
17
+ onClose,
18
+ onSubmit,
19
+ }) {
20
+ const [image, setImage] = useState('')
21
+ const [manual, setManual] = useState(false)
22
+ const [customImage, setCustomImage] = useState('')
23
+ const [name, setName] = useState('')
24
+ const [command, setCommand] = useState('')
25
+ const [ports, setPorts] = useState('')
26
+ const [env, setEnv] = useState('')
27
+ const [error, setError] = useState(null)
28
+ const [busy, setBusy] = useState(false)
29
+ const images = useDockerList(api, provider, 'images', context, {
30
+ refreshToken,
31
+ enabled: open && features.includes('run') && status?.daemon?.state === 'running',
32
+ })
33
+
34
+ const options = useMemo(() => {
35
+ const names = []
36
+ for (const item of images.data?.items ?? []) {
37
+ if (typeof item.name === 'string' && item.name !== '' && item.name !== '<none>' && !names.includes(item.name)) names.push(item.name)
38
+ }
39
+ if (initialImage !== '' && !names.includes(initialImage)) names.unshift(initialImage)
40
+ return names
41
+ }, [images.data, initialImage])
42
+
43
+ useEffect(() => {
44
+ if (!open) return
45
+ setImage(initialImage)
46
+ setManual(false)
47
+ setCustomImage(initialImage)
48
+ setName('')
49
+ setCommand('')
50
+ setPorts('')
51
+ setEnv('')
52
+ setError(null)
53
+ setBusy(false)
54
+ }, [open, initialImage])
55
+
56
+ useEffect(() => {
57
+ if (!open || manual) return
58
+ if (initialImage !== '' && options.includes(initialImage)) {
59
+ setImage(initialImage)
60
+ return
61
+ }
62
+ if (image === '' || !options.includes(image)) {
63
+ if (options.length > 0) setImage(options[0])
64
+ }
65
+ }, [open, options, image, manual, initialImage])
66
+
67
+ const reset = () => {
68
+ setImage('')
69
+ setManual(false)
70
+ setCustomImage('')
71
+ setName('')
72
+ setCommand('')
73
+ setPorts('')
74
+ setEnv('')
75
+ setError(null)
76
+ setBusy(false)
77
+ }
78
+
79
+ const close = () => {
80
+ reset()
81
+ onClose()
82
+ }
83
+
84
+ const submit = async (event) => {
85
+ event?.preventDefault()
86
+ const ref = (manual ? customImage : image).trim()
87
+ if (ref === '') {
88
+ setError(t('runImageRequired'))
89
+ return
90
+ }
91
+ setBusy(true)
92
+ setError(null)
93
+ try {
94
+ await onSubmit({
95
+ kind: 'container',
96
+ action: 'run',
97
+ image: ref,
98
+ name: name.trim() === '' ? undefined : name.trim(),
99
+ command: command.trim() === '' ? undefined : command.trim(),
100
+ ports: ports.split(/[\n,]+/u).map((entry) => entry.trim()).filter(Boolean),
101
+ env: env.split(/\n/u).map((entry) => entry.trim()).filter(Boolean),
102
+ })
103
+ reset()
104
+ onClose()
105
+ } catch (submitError) {
106
+ setError(submitError instanceof Error ? submitError.message : String(submitError))
107
+ setBusy(false)
108
+ }
109
+ }
110
+
111
+ return (
112
+ <Modal
113
+ open={open}
114
+ title={t('runContainerTitle')}
115
+ subtitle={provider === 'apple-container' ? t('providerApple') : t('providerDocker')}
116
+ onClose={close}
117
+ closeLabel={t('close')}
118
+ footer={
119
+ <>
120
+ <Button size="sm" variant="ghost" onClick={close} disabled={busy}>{t('modalCancel')}</Button>
121
+ <Button size="sm" variant="primary" onClick={submit} disabled={busy}>
122
+ {busy ? t('starting') : t('runStart')}
123
+ </Button>
124
+ </>
125
+ }
126
+ >
127
+ <form className="dsh-docker-run-form" onSubmit={submit}>
128
+ <label className="dsh-docker-field">
129
+ <span>{t('runImage')}</span>
130
+ <div className="dsh-docker-image-picker">
131
+ <select
132
+ className="dsh-docker-select"
133
+ value={manual ? '__custom__' : image}
134
+ onChange={(event) => {
135
+ if (event.target.value === '__custom__') {
136
+ setManual(true)
137
+ setCustomImage(image)
138
+ } else {
139
+ setManual(false)
140
+ setImage(event.target.value)
141
+ }
142
+ }}
143
+ autoFocus
144
+ >
145
+ <option value="">{t('runImageSelect')}</option>
146
+ {options.map((name) => <option key={name} value={name}>{name}</option>)}
147
+ <option value="__custom__">{t('runImageCustom')}</option>
148
+ </select>
149
+ <Button size="sm" variant="ghost" onClick={images.reload} disabled={images.loading}>{t('refresh')}</Button>
150
+ </div>
151
+ {manual && (
152
+ <input
153
+ className="dsh-docker-input"
154
+ value={customImage}
155
+ onChange={(event) => setCustomImage(event.target.value)}
156
+ placeholder={t('runImagePlaceholder')}
157
+ spellCheck="false"
158
+ />
159
+ )}
160
+ {images.data !== null && options.length === 0 && !manual && (
161
+ <span className="dsh-docker-muted">{t('runImageListEmpty')}</span>
162
+ )}
163
+ </label>
164
+ <div className="dsh-docker-run-grid">
165
+ <label className="dsh-docker-field">
166
+ <span>{t('runName')}</span>
167
+ <input className="dsh-docker-input" value={name} onChange={(event) => setName(event.target.value)} placeholder={t('runNamePlaceholder')} spellCheck="false" />
168
+ </label>
169
+ <label className="dsh-docker-field">
170
+ <span>{t('runPorts')}</span>
171
+ <input className="dsh-docker-input" value={ports} onChange={(event) => setPorts(event.target.value)} placeholder={t('runPortsPlaceholder')} spellCheck="false" />
172
+ </label>
173
+ </div>
174
+ <label className="dsh-docker-field">
175
+ <span>{t('runCommand')}</span>
176
+ <input className="dsh-docker-input" value={command} onChange={(event) => setCommand(event.target.value)} placeholder={t('runCommandPlaceholder')} spellCheck="false" />
177
+ </label>
178
+ <label className="dsh-docker-field">
179
+ <span>{t('runEnv')}</span>
180
+ <textarea className="dsh-docker-input dsh-docker-textarea" value={env} onChange={(event) => setEnv(event.target.value)} placeholder={t('runEnvPlaceholder')} spellCheck="false" rows={3} />
181
+ </label>
182
+ <div className="dsh-docker-muted">{t('runDetachedHint')}</div>
183
+ {error !== null && <div className="dsh-docker-form-error">{error}</div>}
184
+ </form>
185
+ </Modal>
186
+ )
187
+ }
@@ -0,0 +1,182 @@
1
+ /** Single active command controller shared by the console and quick actions. */
2
+
3
+ import { DockerApiError, isAbortError } from './api.js'
4
+
5
+ const POLL_MS = 400
6
+ const UI_OUTPUT_LIMIT = 1_500_000
7
+
8
+ /** @param {number} ms */
9
+ function sleep(ms) {
10
+ return new Promise((resolve) => setTimeout(resolve, ms))
11
+ }
12
+
13
+ /** @param {string} text */
14
+ function trimOutput(text) {
15
+ if (text.length <= UI_OUTPUT_LIMIT) return { text, truncated: false }
16
+ return { text: `…[前面的输出已省略]\n${text.slice(-UI_OUTPUT_LIMIT)}`, truncated: true }
17
+ }
18
+
19
+ export class RunController {
20
+ /** @param {{call: Function}} api */
21
+ constructor(api) {
22
+ this.api = api
23
+ this.snapshot = {
24
+ run: null,
25
+ stdout: '',
26
+ stderr: '',
27
+ truncated: false,
28
+ error: null,
29
+ }
30
+ this.subscribers = new Set()
31
+ this.generation = 0
32
+ this.stdoutOffset = 0
33
+ this.stderrOffset = 0
34
+ this.polling = false
35
+ this.onFinished = undefined
36
+ }
37
+
38
+ subscribe = (listener) => {
39
+ this.subscribers.add(listener)
40
+ return () => this.subscribers.delete(listener)
41
+ }
42
+
43
+ getSnapshot = () => this.snapshot
44
+
45
+ /** @param {Partial<typeof this.snapshot>} patch */
46
+ update(patch) {
47
+ this.snapshot = { ...this.snapshot, ...patch }
48
+ for (const listener of [...this.subscribers]) listener()
49
+ }
50
+
51
+ get active() {
52
+ const status = this.snapshot.run?.status
53
+ return status === 'running' || status === 'cancelling'
54
+ }
55
+
56
+ /**
57
+ * Start one docker command. Throws DockerApiError on validation/transport
58
+ * errors, including `docker/confirmation-required`.
59
+ */
60
+ async start(input) {
61
+ if (this.active) throw new DockerApiError('docker/run-active', '已有命令正在运行,请先取消')
62
+ const generation = ++this.generation
63
+ this.polling = false
64
+ this.stdoutOffset = 0
65
+ this.stderrOffset = 0
66
+ this.update({ run: null, stdout: '', stderr: '', truncated: false, error: null })
67
+ const value = await this.api.call('run', input)
68
+ if (generation !== this.generation) return value?.run ?? null
69
+ this.update({ run: value?.run ?? null })
70
+ if (value?.run !== undefined && value.run !== null) this.beginPolling(value.run.id, generation)
71
+ return value?.run ?? null
72
+ }
73
+
74
+ /** Start a runtime lifecycle action (colima/Docker Desktop/OrbStack). */
75
+ async startRuntime(action, context, confirm = false, provider = 'docker') {
76
+ if (this.active) throw new DockerApiError('docker/run-active', '已有命令正在运行,请先取消')
77
+ const generation = ++this.generation
78
+ this.polling = false
79
+ this.stdoutOffset = 0
80
+ this.stderrOffset = 0
81
+ this.update({ run: null, stdout: '', stderr: '', truncated: false, error: null })
82
+ const value = await this.api.call('runtimeAction', { provider, action, context: context ?? undefined, confirm })
83
+ if (generation !== this.generation) return value
84
+ if (value?.run !== undefined && value.run !== null) {
85
+ this.update({ run: value.run })
86
+ this.beginPolling(value.run.id, generation)
87
+ }
88
+ return value
89
+ }
90
+
91
+ /** Start one semantic quick action (provider-specific mapping lives on the host). */
92
+ async startAction(input) {
93
+ if (this.active) throw new DockerApiError('docker/run-active', '已有命令正在运行,请先取消')
94
+ const generation = ++this.generation
95
+ this.polling = false
96
+ this.stdoutOffset = 0
97
+ this.stderrOffset = 0
98
+ this.update({ run: null, stdout: '', stderr: '', truncated: false, error: null })
99
+ const value = await this.api.call('action', input)
100
+ if (generation !== this.generation) return value?.run ?? null
101
+ this.update({ run: value?.run ?? null })
102
+ if (value?.run !== undefined && value.run !== null) this.beginPolling(value.run.id, generation)
103
+ return value?.run ?? null
104
+ }
105
+
106
+ /** @param {string} runId @param {number} generation */
107
+ beginPolling(runId, generation) {
108
+ this.polling = true
109
+ const loop = async () => {
110
+ while (this.polling && generation === this.generation) {
111
+ await sleep(POLL_MS)
112
+ if (!this.polling || generation !== this.generation) return
113
+ let value
114
+ try {
115
+ value = await this.api.call('poll', {
116
+ runId,
117
+ stdoutOffset: this.stdoutOffset,
118
+ stderrOffset: this.stderrOffset,
119
+ })
120
+ } catch (error) {
121
+ if (isAbortError(error)) return
122
+ this.update({ error: error instanceof Error ? error.message : String(error) })
123
+ this.polling = false
124
+ return
125
+ }
126
+ if (generation !== this.generation) return
127
+ const stdout = `${this.snapshot.stdout}${value.stdout?.text ?? ''}`
128
+ const stderr = `${this.snapshot.stderr}${value.stderr?.text ?? ''}`
129
+ this.stdoutOffset = value.stdout?.nextOffset ?? this.stdoutOffset
130
+ this.stderrOffset = value.stderr?.nextOffset ?? this.stderrOffset
131
+ const trimmed = trimOutput(stdout)
132
+ const trimmedErr = trimOutput(stderr)
133
+ const run = value.run ?? this.snapshot.run
134
+ this.update({
135
+ run,
136
+ stdout: trimmed.text,
137
+ stderr: trimmedErr.text,
138
+ truncated: this.snapshot.truncated || trimmed.truncated || trimmedErr.truncated || value.stdout?.lossy === true || value.stderr?.lossy === true,
139
+ })
140
+ const status = run?.status
141
+ if (status !== 'running' && status !== 'cancelling') {
142
+ this.polling = false
143
+ this.onFinished?.(run)
144
+ return
145
+ }
146
+ }
147
+ }
148
+ loop().catch((error) => {
149
+ this.update({ error: error instanceof Error ? error.message : String(error) })
150
+ this.polling = false
151
+ })
152
+ }
153
+
154
+ async cancel() {
155
+ const run = this.snapshot.run
156
+ if (run === null || (run.status !== 'running' && run.status !== 'cancelling')) return
157
+ try {
158
+ const value = await this.api.call('cancel', { runId: run.id })
159
+ this.update({ run: value?.run ?? run })
160
+ } catch (error) {
161
+ if (!isAbortError(error)) this.update({ error: error instanceof Error ? error.message : String(error) })
162
+ }
163
+ }
164
+
165
+ reset() {
166
+ this.generation += 1
167
+ this.polling = false
168
+ this.stdoutOffset = 0
169
+ this.stderrOffset = 0
170
+ this.update({ run: null, stdout: '', stderr: '', truncated: false, error: null })
171
+ }
172
+
173
+ dispose() {
174
+ this.generation += 1
175
+ this.polling = false
176
+ const run = this.snapshot.run
177
+ if (run !== null && (run.status === 'running' || run.status === 'cancelling')) {
178
+ this.api.call('cancel', { runId: run.id }).catch(() => {})
179
+ }
180
+ this.subscribers.clear()
181
+ }
182
+ }
@@ -0,0 +1,91 @@
1
+ /** Browser-local view state: active tab, selected context, console cwd. */
2
+ import { defineStore } from '@deepseek-ai/dsh-client-store'
3
+
4
+ const TAB_KEY = 'dsh.docker.tab'
5
+ const CONTEXT_KEY = 'dsh.docker.context'
6
+ const CWD_KEY = 'dsh.docker.cwd'
7
+ const PROVIDER_KEY = 'dsh.docker.provider'
8
+ const HISTORY_KEY = 'dsh.docker.history'
9
+ const VALID_TABS = new Set(['overview', 'containers', 'images', 'volumes', 'networks', 'compose', 'console'])
10
+ const VALID_PROVIDERS = new Set(['docker', 'apple-container'])
11
+
12
+ /** @param {string} key */
13
+ function read(key) {
14
+ try {
15
+ return typeof localStorage === 'undefined' ? null : localStorage.getItem(key)
16
+ } catch {
17
+ return null
18
+ }
19
+ }
20
+
21
+ /** @param {string} key @param {string | null} value */
22
+ function write(key, value) {
23
+ try {
24
+ if (typeof localStorage === 'undefined') return
25
+ if (value === null || value === '') localStorage.removeItem(key)
26
+ else localStorage.setItem(key, value)
27
+ } catch {
28
+ // Storage can be unavailable in private contexts; view state still works.
29
+ }
30
+ }
31
+
32
+ export function readHistory() {
33
+ try {
34
+ const raw = read(HISTORY_KEY)
35
+ if (raw === null) return []
36
+ const parsed = JSON.parse(raw)
37
+ return Array.isArray(parsed) ? parsed.filter((entry) => typeof entry === 'string').slice(0, 50) : []
38
+ } catch {
39
+ return []
40
+ }
41
+ }
42
+
43
+ /** @param {string[]} entries */
44
+ export function writeHistory(entries) {
45
+ try {
46
+ write(HISTORY_KEY, JSON.stringify(entries.slice(0, 50)))
47
+ } catch {
48
+ // ignore
49
+ }
50
+ }
51
+
52
+ export function createDockerStore() {
53
+ return defineStore({
54
+ init: () => {
55
+ const storedTab = read(TAB_KEY)
56
+ const storedContext = read(CONTEXT_KEY)
57
+ const storedCwd = read(CWD_KEY)
58
+ const storedProvider = read(PROVIDER_KEY)
59
+ return {
60
+ provider: storedProvider !== null && VALID_PROVIDERS.has(storedProvider) ? storedProvider : 'docker',
61
+ tab: storedTab !== null && VALID_TABS.has(storedTab) ? storedTab : 'overview',
62
+ context: storedContext !== null && storedContext !== '' ? storedContext : null,
63
+ cwd: storedCwd ?? '',
64
+ }
65
+ },
66
+ actions: {
67
+ /** @param {{provider: string}} draft @param {string} provider */
68
+ setProvider(draft, provider) {
69
+ if (!VALID_PROVIDERS.has(provider)) return
70
+ draft.provider = provider
71
+ write(PROVIDER_KEY, provider)
72
+ },
73
+ /** @param {{tab: string}} draft @param {string} tab */
74
+ setTab(draft, tab) {
75
+ if (!VALID_TABS.has(tab)) return
76
+ draft.tab = tab
77
+ write(TAB_KEY, tab)
78
+ },
79
+ /** @param {{context: string | null}} draft @param {string | null} context */
80
+ setContext(draft, context) {
81
+ draft.context = context === '' ? null : context
82
+ write(CONTEXT_KEY, draft.context)
83
+ },
84
+ /** @param {{cwd: string}} draft @param {string} cwd */
85
+ setCwd(draft, cwd) {
86
+ draft.cwd = cwd
87
+ write(CWD_KEY, cwd)
88
+ },
89
+ },
90
+ })
91
+ }