dsh-wsl-workspace 0.1.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/LICENSE +21 -0
- package/NOTICE +68 -0
- package/README.md +30 -0
- package/README.zh.md +30 -0
- package/cordis.patch.yml +7 -0
- package/lib/client.js +982 -0
- package/lib/client.js.map +1 -0
- package/lib/fs.js +175 -0
- package/lib/fs.js.map +1 -0
- package/lib/index.js +493 -0
- package/lib/index.js.map +1 -0
- package/lib/paths-DBaSmi7x.js +105 -0
- package/lib/paths-DBaSmi7x.js.map +1 -0
- package/lib/shell.js +382 -0
- package/lib/shell.js.map +1 -0
- package/lib/wsl-GjkUifnx.js +179 -0
- package/lib/wsl-GjkUifnx.js.map +1 -0
- package/package.json +57 -0
- package/src/client/AddWslWorkspace.tsx +346 -0
- package/src/client/api.ts +104 -0
- package/src/client/index.ts +193 -0
- package/src/client/locales.ts +68 -0
- package/src/client/styles.ts +282 -0
- package/src/fs.ts +228 -0
- package/src/host/variants.ts +199 -0
- package/src/index.ts +410 -0
- package/src/shared/paths.ts +159 -0
- package/src/shared/wsl-credentials.ts +85 -0
- package/src/shared/wsl.ts +105 -0
- package/src/shell.ts +441 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin fetch client for the Host plugin route. The browser calls
|
|
3
|
+
* POST /wsl-workspace/api with a `{ method, params }` envelope and the Host
|
|
4
|
+
* answers `{ ok: true, value }` or `{ ok: false, error }`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Relative route the Host half registers (same-origin with the web server). */
|
|
8
|
+
const ENDPOINT = '/wsl-workspace/api'
|
|
9
|
+
|
|
10
|
+
/** One directory entry as the Host lists it. */
|
|
11
|
+
export interface WslDirEntry {
|
|
12
|
+
name: string
|
|
13
|
+
kind: 'directory' | 'file' | 'other'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** One directory level plus its breadcrumb ancestry. */
|
|
17
|
+
export interface WslDirListing {
|
|
18
|
+
/** The listed absolute Linux path. */
|
|
19
|
+
path: string
|
|
20
|
+
/** Parent Linux path, or null at the filesystem root. */
|
|
21
|
+
parent: string | null
|
|
22
|
+
/** The level's children (in name order; the client filters to directories). */
|
|
23
|
+
entries: WslDirEntry[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Existence/directory check result for one Linux path. */
|
|
27
|
+
export interface WslPathCheck {
|
|
28
|
+
exists: boolean
|
|
29
|
+
isDirectory: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Wire envelope the Host route answers with. */
|
|
33
|
+
type Envelope<T> = { ok: true; value: T } | { ok: false; error: string }
|
|
34
|
+
|
|
35
|
+
/** Human text for an unknown rejection, reusing the repository's idiom. */
|
|
36
|
+
function errorMessage(value: unknown): string {
|
|
37
|
+
return value instanceof Error ? value.message : String(value)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Perform one POST call and unwrap the envelope.
|
|
42
|
+
* @param method - the Host method name.
|
|
43
|
+
* @param params - the method payload.
|
|
44
|
+
* @returns the unwrapped value, or throws an Error on network or `ok:false`.
|
|
45
|
+
*/
|
|
46
|
+
async function call<T>(method: string, params: Record<string, unknown> = {}): Promise<T> {
|
|
47
|
+
let response: Response
|
|
48
|
+
try {
|
|
49
|
+
response = await fetch(ENDPOINT, {
|
|
50
|
+
method: 'POST',
|
|
51
|
+
headers: { 'content-type': 'application/json' },
|
|
52
|
+
body: JSON.stringify({ method, params }),
|
|
53
|
+
})
|
|
54
|
+
} catch (error) {
|
|
55
|
+
// The transport refused before answering (offline, origin mismatch, 404).
|
|
56
|
+
throw new Error(`wsl-workspace request failed: ${errorMessage(error)}`)
|
|
57
|
+
}
|
|
58
|
+
let envelope: Envelope<T>
|
|
59
|
+
try {
|
|
60
|
+
envelope = (await response.json()) as Envelope<T>
|
|
61
|
+
} catch {
|
|
62
|
+
// A non-JSON body means a proxy/loader answered instead of the Host route.
|
|
63
|
+
throw new Error(`wsl-workspace answered non-JSON (${response.status})`)
|
|
64
|
+
}
|
|
65
|
+
if (!envelope.ok) throw new Error(envelope.error)
|
|
66
|
+
return envelope.value
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* List the WSL distros installed on the host.
|
|
71
|
+
* @returns distro names in registry order.
|
|
72
|
+
*/
|
|
73
|
+
export async function listDistros(): Promise<string[]> {
|
|
74
|
+
return call<string[]>('listDistros', {})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* List one directory level inside a distro.
|
|
79
|
+
* @param distro - distro name.
|
|
80
|
+
* @param path - absolute Linux directory to list.
|
|
81
|
+
* @returns the level's listing with ancestry.
|
|
82
|
+
*/
|
|
83
|
+
export async function listDir(distro: string, path: string): Promise<WslDirListing> {
|
|
84
|
+
return call<WslDirListing>('listDir', { distro, path })
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Check whether a Linux path exists and is a directory.
|
|
89
|
+
* @param distro - distro name.
|
|
90
|
+
* @param path - absolute Linux path.
|
|
91
|
+
* @returns existence and directory facts.
|
|
92
|
+
*/
|
|
93
|
+
export async function check(distro: string, path: string): Promise<WslPathCheck> {
|
|
94
|
+
return call<WslPathCheck>('check', { distro, path })
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Store (or clear, with an empty string) the username of one WSL workspace.
|
|
99
|
+
* @param path - the workspace UNC path.
|
|
100
|
+
* @param username - the Linux username; empty string clears the stored value.
|
|
101
|
+
*/
|
|
102
|
+
export async function setWorkspaceUser(path: string, username: string): Promise<void> {
|
|
103
|
+
return call<void>('setUser', { path, username })
|
|
104
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser half of dsh-wsl-workspace. Registers the "Add WSL workspace…"
|
|
3
|
+
* action beside Settings at the sidebar foot (the official
|
|
4
|
+
* `sidebar.footer.action` slot), and keeps every blank session whose
|
|
5
|
+
* workspace is a WSL UNC path composed from the WSL VARIANT of the mode it
|
|
6
|
+
* currently runs (`standard` → `wsl-standard`, PTC → `wsl-code`, …) — so the
|
|
7
|
+
* WSL execution world composes with any mode instead of being a mode itself.
|
|
8
|
+
*
|
|
9
|
+
* The binding is a watching effect rather than a one-shot dialog action so
|
|
10
|
+
* EVERY creation path (this dialog, the workspace row's New Session, the
|
|
11
|
+
* hero picker) converges on the WSL-backed composition automatically.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client'
|
|
15
|
+
// Type-only: pulls the locale plugin's Context merge (ctx.locale), the
|
|
16
|
+
// runtime's ClientContext, and the ui-sidebar SlotMap merge (the
|
|
17
|
+
// 'sidebar.footer.action' entry) into this program.
|
|
18
|
+
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
|
19
|
+
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
|
20
|
+
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
|
21
|
+
import { check as checkApi, listDir as listDirApi, listDistros as listDistrosApi, setWorkspaceUser as setWorkspaceUserApi } from './api.ts'
|
|
22
|
+
import { AddWslWorkspace, type AddWslWorkspaceInjected } from './AddWslWorkspace.tsx'
|
|
23
|
+
import { ensureStyles } from './styles.ts'
|
|
24
|
+
import { zh, en } from './locales.ts'
|
|
25
|
+
import { isWslUnc } from '../shared/paths.ts'
|
|
26
|
+
|
|
27
|
+
/** Required services (cordis fiber inject). */
|
|
28
|
+
export const inject = ['slots', 'locale', 'connection', 'sessions', 'workspaces']
|
|
29
|
+
|
|
30
|
+
/** The legacy standalone WSL preset id (folded into the mode variants). */
|
|
31
|
+
const LEGACY_WSL_PRESET_ID = 'wsl'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Minimal sessions-service face. The renderer-host ctx merge types
|
|
35
|
+
* `ctx.sessions` as its own SessionStore; the service the runtime actually
|
|
36
|
+
* registers under that key satisfies this narrower contract, so the cast is
|
|
37
|
+
* the documented boundary for a third-party plugin.
|
|
38
|
+
*/
|
|
39
|
+
interface WslSessionsFace {
|
|
40
|
+
list: {
|
|
41
|
+
getSnapshot(): { ids: string[]; byId: Record<string, { blank: boolean; cwd?: string; agentPreset?: string }> }
|
|
42
|
+
subscribe(fn: () => void): () => void
|
|
43
|
+
}
|
|
44
|
+
noteAgentPreset(sessionId: string, agentPreset: string): void
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Minimal workspaces-service face (create + start-session only). */
|
|
48
|
+
interface WslWorkspacesFace {
|
|
49
|
+
create(input: { path: string }): Promise<{ workspaceId: string }>
|
|
50
|
+
startSession(workspaceId?: string): void
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Mount the sidebar action and the auto-binding effect.
|
|
55
|
+
* @param ctx - the browser plugin context.
|
|
56
|
+
*/
|
|
57
|
+
export function apply(ctx: ClientContext): void {
|
|
58
|
+
const { api } = ctx.get('connection') as ConnectionHandle
|
|
59
|
+
const workspaces = ctx.get('workspaces') as unknown as WslWorkspacesFace
|
|
60
|
+
const sessions = ctx.get('sessions') as unknown as WslSessionsFace
|
|
61
|
+
|
|
62
|
+
ensureStyles()
|
|
63
|
+
|
|
64
|
+
ctx.effect(
|
|
65
|
+
() => ctx.locale.register('wslWorkspace' as never, { zh, en }),
|
|
66
|
+
'dsh-wsl-workspace: locale dictionaries',
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
// The injected translate function reads the live locale preference.
|
|
70
|
+
const t = ctx.locale.bind('wslWorkspace' as never) as unknown as (key: string, params?: Record<string, unknown>) => string
|
|
71
|
+
|
|
72
|
+
const injected = (): AddWslWorkspaceInjected => ({
|
|
73
|
+
t,
|
|
74
|
+
checkPreset: async (): Promise<string | undefined> => {
|
|
75
|
+
let roster
|
|
76
|
+
try {
|
|
77
|
+
const response = await api.agentPresets.list({})
|
|
78
|
+
roster = response.result
|
|
79
|
+
} catch (error) {
|
|
80
|
+
return error instanceof Error ? error.message : String(error)
|
|
81
|
+
}
|
|
82
|
+
if (!roster.ok) return roster.error.message
|
|
83
|
+
const healthy = roster.value.presets.find((entry: { id: string; broken?: string }) =>
|
|
84
|
+
entry.id.startsWith('wsl-') && entry.broken === undefined)
|
|
85
|
+
if (healthy === undefined) return t('error.presetMissing')
|
|
86
|
+
return undefined
|
|
87
|
+
},
|
|
88
|
+
listDistros: () => listDistrosApi(),
|
|
89
|
+
listDir: (distro, path) => listDirApi(distro, path),
|
|
90
|
+
check: (distro, path) => checkApi(distro, path),
|
|
91
|
+
createWorkspace: async (path, username): Promise<string | undefined> => {
|
|
92
|
+
try {
|
|
93
|
+
const view = await workspaces.create({ path })
|
|
94
|
+
await setWorkspaceUserApi(path, username)
|
|
95
|
+
workspaces.startSession(view.workspaceId)
|
|
96
|
+
return undefined
|
|
97
|
+
} catch (error) {
|
|
98
|
+
return error instanceof Error ? error.message : String(error)
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
ctx.effect(
|
|
104
|
+
() => ctx.slots.inject(
|
|
105
|
+
'sidebar.footer.action',
|
|
106
|
+
() => ctx.slots.register(
|
|
107
|
+
{ name: 'sidebar.footer.action', id: 'wsl-workspace', inject: injected },
|
|
108
|
+
AddWslWorkspace,
|
|
109
|
+
),
|
|
110
|
+
),
|
|
111
|
+
'dsh-wsl-workspace: sidebar footer action',
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
// Mode-variant binding: a blank session whose workspace is a WSL UNC path
|
|
115
|
+
// is recomposed to the WSL variant of the mode it currently runs — plain
|
|
116
|
+
// 标准 becomes `wsl-standard`, PTC becomes `wsl-code`, and so on — so the
|
|
117
|
+
// WSL execution world composes with ANY mode instead of replacing it. The
|
|
118
|
+
// host refuses non-blank sessions (agent-preset-locked), so the swap is
|
|
119
|
+
// attempted at most a few times per session.
|
|
120
|
+
ctx.effect(() => {
|
|
121
|
+
const inFlight = new Set<string>()
|
|
122
|
+
const attempts = new Map<string, number>()
|
|
123
|
+
const MAX_ATTEMPTS = 3
|
|
124
|
+
// Healthy `wsl-<mode>` variant ids plus the roster's default preset id
|
|
125
|
+
// (what a session with no explicit choice gets). Refreshed periodically
|
|
126
|
+
// so variants generated after this page loaded are picked up.
|
|
127
|
+
let variants = new Set<string>()
|
|
128
|
+
let defaultPreset: string | undefined
|
|
129
|
+
const refreshRoster = (): void => {
|
|
130
|
+
void api.agentPresets.list({}).then((response: {
|
|
131
|
+
result: { ok: boolean; value: { presets: { id: string; broken?: string; isDefault?: boolean }[] } }
|
|
132
|
+
}) => {
|
|
133
|
+
const result = response.result
|
|
134
|
+
if (!result.ok) return
|
|
135
|
+
variants = new Set(result.value.presets
|
|
136
|
+
.filter((entry: { id: string; broken?: string }) =>
|
|
137
|
+
entry.broken === undefined && entry.id.startsWith('wsl-'))
|
|
138
|
+
.map((entry: { id: string }) => entry.id))
|
|
139
|
+
defaultPreset = result.value.presets.find(
|
|
140
|
+
(entry: { id: string; isDefault?: boolean }) => entry.isDefault === true,
|
|
141
|
+
)?.id
|
|
142
|
+
}).catch(() => {
|
|
143
|
+
// A failed roster read leaves the previous mapping; sessions stay on
|
|
144
|
+
// their current composition until the next refresh.
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
refreshRoster()
|
|
148
|
+
const maybeBind = (): void => {
|
|
149
|
+
const state = sessions.list.getSnapshot()
|
|
150
|
+
for (const id of state.ids) {
|
|
151
|
+
const summary = state.byId[id]
|
|
152
|
+
if (summary === undefined || !summary.blank || summary.cwd === undefined) continue
|
|
153
|
+
if (!isWslUnc(summary.cwd)) continue
|
|
154
|
+
const current = summary.agentPreset
|
|
155
|
+
if (current !== undefined && current.startsWith('wsl-')) continue
|
|
156
|
+
// Legacy standalone `wsl` (now folded into the variants): remap it to
|
|
157
|
+
// the default mode's variant, since the standalone preset no longer
|
|
158
|
+
// exists in the roster.
|
|
159
|
+
const base = current === LEGACY_WSL_PRESET_ID
|
|
160
|
+
? (defaultPreset ?? 'standard')
|
|
161
|
+
: (current ?? defaultPreset)
|
|
162
|
+
if (base === undefined || base === LEGACY_WSL_PRESET_ID || base.startsWith('wsl-')) continue
|
|
163
|
+
const target = `wsl-${base.toLowerCase()}`
|
|
164
|
+
if (!variants.has(target)) continue
|
|
165
|
+
if (inFlight.has(id) || (attempts.get(id) ?? 0) >= MAX_ATTEMPTS) continue
|
|
166
|
+
inFlight.add(id)
|
|
167
|
+
void api.agentPresets.select({ sessionId: id, agentPreset: target })
|
|
168
|
+
.then((response: { result: { ok: boolean } }) => {
|
|
169
|
+
if (response.result.ok) sessions.noteAgentPreset(id, target)
|
|
170
|
+
})
|
|
171
|
+
.catch(() => {
|
|
172
|
+
// A refused or aborted swap (session already produced output,
|
|
173
|
+
// roster churn, reconnect) leaves the session on its current
|
|
174
|
+
// composition; count the attempt so a stuck session stops
|
|
175
|
+
// retrying after MAX_ATTEMPTS.
|
|
176
|
+
attempts.set(id, (attempts.get(id) ?? 0) + 1)
|
|
177
|
+
})
|
|
178
|
+
.finally(() => {
|
|
179
|
+
inFlight.delete(id)
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
maybeBind()
|
|
184
|
+
const unsubscribe = sessions.list.subscribe(() => maybeBind())
|
|
185
|
+
// Variants are generated at host boot; a page loaded before that would
|
|
186
|
+
// never see them without a periodic refresh.
|
|
187
|
+
const timer = window.setInterval(refreshRoster, 60_000)
|
|
188
|
+
return () => {
|
|
189
|
+
unsubscribe()
|
|
190
|
+
window.clearInterval(timer)
|
|
191
|
+
}
|
|
192
|
+
}, 'dsh-wsl-workspace: WSL mode-variant binding')
|
|
193
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bilingual dictionaries for the `wslWorkspace` locale namespace. Product copy
|
|
3
|
+
* is Chinese; English is the parallel export for the standalone bundle.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The `wslWorkspace` translations (Chinese, the primary product copy).
|
|
8
|
+
*/
|
|
9
|
+
export const zh: Record<string, string> = {
|
|
10
|
+
'action.add': 'WSL 工作区',
|
|
11
|
+
'action.title': '添加 WSL 工作区…',
|
|
12
|
+
|
|
13
|
+
'dialog.title': '添加 WSL 工作区',
|
|
14
|
+
'dialog.distro': '发行版',
|
|
15
|
+
'dialog.path': '路径',
|
|
16
|
+
'dialog.pathPlaceholder': '/home/',
|
|
17
|
+
'dialog.username': '用户名',
|
|
18
|
+
'dialog.usernamePlaceholder': '留空则使用发行版默认用户',
|
|
19
|
+
'dialog.loading': '正在加载…',
|
|
20
|
+
'dialog.browseEmpty': '此目录没有子文件夹',
|
|
21
|
+
'dialog.upLevel': '..(返回上级)',
|
|
22
|
+
'dialog.browse': '浏览',
|
|
23
|
+
'dialog.check': '检查',
|
|
24
|
+
'dialog.confirm': '创建并打开',
|
|
25
|
+
'dialog.cancel': '取消',
|
|
26
|
+
'dialog.retry': '重试',
|
|
27
|
+
|
|
28
|
+
'error.loadDistros': '无法获取 WSL 发行版列表,请确认已安装 WSL 且插件宿主端可用',
|
|
29
|
+
'error.rateLimited': '操作过于频繁,请稍后重试',
|
|
30
|
+
'error.loadDir': '无法浏览该目录',
|
|
31
|
+
'error.presetMissing': '未找到健康的 wsl preset,请确认插件宿主端已安装并配置该 preset',
|
|
32
|
+
'error.invalidPath': '请输入以 / 开头的 Linux 绝对路径',
|
|
33
|
+
'error.invalidUsername': '用户名无效:需以字母或下划线开头,仅含字母、数字、_、.、-',
|
|
34
|
+
'error.pathNotFound': '该路径不存在或是文件,请选择一个文件夹',
|
|
35
|
+
'error.createFailed': '创建工作区失败',
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The `wslWorkspace` translations (English).
|
|
40
|
+
*/
|
|
41
|
+
export const en: Record<string, string> = {
|
|
42
|
+
'action.add': 'WSL Workspace',
|
|
43
|
+
'action.title': 'Add WSL workspace…',
|
|
44
|
+
|
|
45
|
+
'dialog.title': 'Add WSL workspace',
|
|
46
|
+
'dialog.distro': 'Distro',
|
|
47
|
+
'dialog.path': 'Path',
|
|
48
|
+
'dialog.pathPlaceholder': '/home/',
|
|
49
|
+
'dialog.username': 'Username',
|
|
50
|
+
'dialog.usernamePlaceholder': 'Leave empty to use the distro default user',
|
|
51
|
+
'dialog.loading': 'Loading…',
|
|
52
|
+
'dialog.browseEmpty': 'No subdirectories here',
|
|
53
|
+
'dialog.upLevel': '.. (up)',
|
|
54
|
+
'dialog.browse': 'Browse',
|
|
55
|
+
'dialog.check': 'Check',
|
|
56
|
+
'dialog.confirm': 'Create & open',
|
|
57
|
+
'dialog.cancel': 'Cancel',
|
|
58
|
+
'dialog.retry': 'Retry',
|
|
59
|
+
|
|
60
|
+
'error.loadDistros': 'Could not list WSL distros; confirm WSL is installed and the plugin host side is reachable',
|
|
61
|
+
'error.rateLimited': 'Too many attempts; retry in a moment',
|
|
62
|
+
'error.loadDir': 'Could not browse this directory',
|
|
63
|
+
'error.presetMissing': 'No healthy "wsl" preset found; confirm the plugin host side installed and configured it',
|
|
64
|
+
'error.invalidPath': 'Enter an absolute Linux path starting with /',
|
|
65
|
+
'error.invalidUsername': 'Invalid username: start with a letter or underscore; only letters, digits, _ . -',
|
|
66
|
+
'error.pathNotFound': 'The path does not exist or is a file; choose a folder',
|
|
67
|
+
'error.createFailed': 'Failed to create the workspace',
|
|
68
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Third-party stylesheet injection for the WSL workspace UI (the plugin
|
|
3
|
+
* builds no CSS bundle, so styles are injected as one idempotent `<style>`).
|
|
4
|
+
* Colors derive exclusively from the `--dsw-*` design tokens.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const STYLE_TAG_DATA_ATTRIBUTE = 'data-plugin="dsh-wsl-workspace"'
|
|
8
|
+
|
|
9
|
+
const STYLES = `
|
|
10
|
+
/* Sidebar-foot icon action beside Settings (28px round in the wide sidebar,
|
|
11
|
+
36px round in the rail), matching the shell's icon-button language. */
|
|
12
|
+
.dww-action {
|
|
13
|
+
flex: none;
|
|
14
|
+
display: inline-flex;
|
|
15
|
+
align-items: center;
|
|
16
|
+
justify-content: center;
|
|
17
|
+
width: 28px;
|
|
18
|
+
height: 28px;
|
|
19
|
+
border: none;
|
|
20
|
+
border-radius: 50%;
|
|
21
|
+
padding: 0;
|
|
22
|
+
background: transparent;
|
|
23
|
+
cursor: pointer;
|
|
24
|
+
color: var(--dsw-alias-label-secondary);
|
|
25
|
+
transition:
|
|
26
|
+
background-color 120ms var(--dsw-ease-in-out, ease-in-out),
|
|
27
|
+
color 120ms var(--dsw-ease-in-out, ease-in-out);
|
|
28
|
+
}
|
|
29
|
+
.dww-action:hover:not(:disabled) {
|
|
30
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
31
|
+
color: var(--dsw-alias-label-secondary);
|
|
32
|
+
}
|
|
33
|
+
.dww-action:active:not(:disabled) {
|
|
34
|
+
background: var(--dsw-alias-interactive-bg-pressed, var(--dsw-alias-interactive-bg-hover));
|
|
35
|
+
}
|
|
36
|
+
.dww-action:focus-visible {
|
|
37
|
+
outline: 2px solid var(--dsw-alias-state-business-primary);
|
|
38
|
+
outline-offset: 1px;
|
|
39
|
+
}
|
|
40
|
+
.dww-action:disabled { cursor: default; opacity: 0.6; }
|
|
41
|
+
.dww-action--rail {
|
|
42
|
+
width: 36px;
|
|
43
|
+
height: 36px;
|
|
44
|
+
color: var(--dsw-alias-label-primary);
|
|
45
|
+
}
|
|
46
|
+
.dww-action svg { flex: none; }
|
|
47
|
+
|
|
48
|
+
/* The W letter mark of the sidebar action (sized for wide/rail buttons). */
|
|
49
|
+
.dww-letter {
|
|
50
|
+
font-size: 14px;
|
|
51
|
+
font-weight: 600;
|
|
52
|
+
line-height: 1;
|
|
53
|
+
letter-spacing: 0.02em;
|
|
54
|
+
user-select: none;
|
|
55
|
+
}
|
|
56
|
+
.dww-action--rail .dww-letter { font-size: 17px; }
|
|
57
|
+
|
|
58
|
+
/* Full-viewport overlay + centered card (mirrors the platform Mask/Dialog). */
|
|
59
|
+
.dww-overlay {
|
|
60
|
+
position: fixed;
|
|
61
|
+
inset: 0;
|
|
62
|
+
z-index: 1000;
|
|
63
|
+
display: flex;
|
|
64
|
+
align-items: center;
|
|
65
|
+
justify-content: center;
|
|
66
|
+
padding: 24px;
|
|
67
|
+
}
|
|
68
|
+
.dww-overlay-mask {
|
|
69
|
+
position: absolute;
|
|
70
|
+
inset: 0;
|
|
71
|
+
background: var(--dsw-alias-bg-mask-1);
|
|
72
|
+
backdrop-filter: var(--dsw-mask-blur);
|
|
73
|
+
}
|
|
74
|
+
.dww-card {
|
|
75
|
+
position: relative;
|
|
76
|
+
z-index: 1;
|
|
77
|
+
box-sizing: border-box;
|
|
78
|
+
display: flex;
|
|
79
|
+
flex-direction: column;
|
|
80
|
+
width: min(440px, 100%);
|
|
81
|
+
max-height: min(640px, 90vh);
|
|
82
|
+
padding: 0 0 20px;
|
|
83
|
+
overflow: hidden;
|
|
84
|
+
border: 1px solid var(--dsw-alias-border-inverted);
|
|
85
|
+
border-radius: 16px;
|
|
86
|
+
background: var(--dsw-alias-bg-layer-2);
|
|
87
|
+
box-shadow: var(--dsw-shadow-lv3);
|
|
88
|
+
font-family: var(--dsw-font-family);
|
|
89
|
+
}
|
|
90
|
+
.dww-header {
|
|
91
|
+
display: flex;
|
|
92
|
+
align-items: center;
|
|
93
|
+
justify-content: space-between;
|
|
94
|
+
gap: 8px;
|
|
95
|
+
padding: 18px 20px 12px;
|
|
96
|
+
}
|
|
97
|
+
.dww-title {
|
|
98
|
+
margin: 0;
|
|
99
|
+
font-size: 16px;
|
|
100
|
+
line-height: 24px;
|
|
101
|
+
font-weight: 500;
|
|
102
|
+
color: var(--dsw-alias-label-primary);
|
|
103
|
+
}
|
|
104
|
+
.dww-close {
|
|
105
|
+
flex: none;
|
|
106
|
+
display: inline-flex;
|
|
107
|
+
align-items: center;
|
|
108
|
+
justify-content: center;
|
|
109
|
+
width: 28px;
|
|
110
|
+
height: 28px;
|
|
111
|
+
border: 0;
|
|
112
|
+
border-radius: 8px;
|
|
113
|
+
background: transparent;
|
|
114
|
+
cursor: pointer;
|
|
115
|
+
color: var(--dsw-alias-label-secondary);
|
|
116
|
+
}
|
|
117
|
+
.dww-close:hover { background: var(--dsw-alias-interactive-bg-hover); }
|
|
118
|
+
.dww-body {
|
|
119
|
+
display: flex;
|
|
120
|
+
flex-direction: column;
|
|
121
|
+
gap: 14px;
|
|
122
|
+
min-width: 0;
|
|
123
|
+
padding: 0 20px;
|
|
124
|
+
overflow: auto;
|
|
125
|
+
}
|
|
126
|
+
.dww-field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
|
127
|
+
.dww-field-label {
|
|
128
|
+
font-size: 12px;
|
|
129
|
+
line-height: 18px;
|
|
130
|
+
color: var(--dsw-alias-label-secondary);
|
|
131
|
+
}
|
|
132
|
+
.dww-select {
|
|
133
|
+
box-sizing: border-box;
|
|
134
|
+
width: 100%;
|
|
135
|
+
height: 36px;
|
|
136
|
+
padding: 0 10px;
|
|
137
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
138
|
+
border-radius: 8px;
|
|
139
|
+
background: var(--dsw-alias-bg-layer-3);
|
|
140
|
+
color: var(--dsw-alias-label-primary);
|
|
141
|
+
font-size: 14px;
|
|
142
|
+
}
|
|
143
|
+
.dww-input-row { display: flex; gap: 8px; align-items: center; }
|
|
144
|
+
.dww-input {
|
|
145
|
+
box-sizing: border-box;
|
|
146
|
+
flex: 1;
|
|
147
|
+
height: 36px;
|
|
148
|
+
min-width: 0;
|
|
149
|
+
padding: 0 10px;
|
|
150
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
151
|
+
border-radius: 8px;
|
|
152
|
+
background: var(--dsw-alias-bg-layer-3);
|
|
153
|
+
color: var(--dsw-alias-label-primary);
|
|
154
|
+
font-size: 14px;
|
|
155
|
+
}
|
|
156
|
+
.dww-input:focus, .dww-select:focus {
|
|
157
|
+
outline: none;
|
|
158
|
+
border-color: var(--dsw-alias-state-business-primary);
|
|
159
|
+
}
|
|
160
|
+
.dww-check-btn {
|
|
161
|
+
flex: none;
|
|
162
|
+
height: 36px;
|
|
163
|
+
padding: 0 12px;
|
|
164
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
165
|
+
border-radius: 8px;
|
|
166
|
+
background: transparent;
|
|
167
|
+
color: var(--dsw-alias-label-primary);
|
|
168
|
+
cursor: pointer;
|
|
169
|
+
font-size: 12px;
|
|
170
|
+
}
|
|
171
|
+
.dww-check-btn:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }
|
|
172
|
+
.dww-check-btn:disabled { cursor: default; }
|
|
173
|
+
|
|
174
|
+
/* Directory browse list. */
|
|
175
|
+
.dww-dirlist {
|
|
176
|
+
display: flex;
|
|
177
|
+
flex-direction: column;
|
|
178
|
+
gap: 2px;
|
|
179
|
+
box-sizing: border-box;
|
|
180
|
+
min-height: 120px;
|
|
181
|
+
max-height: 200px;
|
|
182
|
+
padding: 4px;
|
|
183
|
+
overflow: auto;
|
|
184
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
185
|
+
border-radius: 8px;
|
|
186
|
+
background: var(--dsw-alias-bg-layer-3);
|
|
187
|
+
}
|
|
188
|
+
.dww-breadcrumb {
|
|
189
|
+
padding: 0 4px;
|
|
190
|
+
font-size: 12px;
|
|
191
|
+
line-height: 18px;
|
|
192
|
+
color: var(--dsw-alias-label-tertiary);
|
|
193
|
+
overflow: hidden;
|
|
194
|
+
white-space: nowrap;
|
|
195
|
+
text-overflow: ellipsis;
|
|
196
|
+
}
|
|
197
|
+
.dww-dir-row {
|
|
198
|
+
display: flex;
|
|
199
|
+
align-items: center;
|
|
200
|
+
gap: 8px;
|
|
201
|
+
height: 28px;
|
|
202
|
+
padding: 0 8px;
|
|
203
|
+
border: 0;
|
|
204
|
+
border-radius: 6px;
|
|
205
|
+
background: transparent;
|
|
206
|
+
color: var(--dsw-alias-label-primary);
|
|
207
|
+
cursor: pointer;
|
|
208
|
+
font-size: 13px;
|
|
209
|
+
text-align: left;
|
|
210
|
+
}
|
|
211
|
+
.dww-dir-row:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }
|
|
212
|
+
.dww-dir-row:disabled { cursor: default; color: var(--dsw-alias-label-tertiary); }
|
|
213
|
+
.dww-dir-row--up { color: var(--dsw-alias-label-secondary); }
|
|
214
|
+
.dww-dir-row svg { flex: none; color: var(--dsw-alias-label-tertiary); }
|
|
215
|
+
.dww-dir-empty {
|
|
216
|
+
padding: 8px;
|
|
217
|
+
font-size: 12px;
|
|
218
|
+
line-height: 18px;
|
|
219
|
+
color: var(--dsw-alias-label-tertiary);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/* Error strip. */
|
|
223
|
+
.dww-error {
|
|
224
|
+
box-sizing: border-box;
|
|
225
|
+
width: 100%;
|
|
226
|
+
padding: 8px 10px;
|
|
227
|
+
border: 1px solid var(--dsw-alias-state-error-primary);
|
|
228
|
+
border-radius: 8px;
|
|
229
|
+
color: var(--dsw-alias-state-error-primary);
|
|
230
|
+
font-size: 12px;
|
|
231
|
+
line-height: 18px;
|
|
232
|
+
}
|
|
233
|
+
.dww-retry {
|
|
234
|
+
margin-left: 6px;
|
|
235
|
+
border: 0;
|
|
236
|
+
background: transparent;
|
|
237
|
+
color: var(--dsw-alias-state-business-primary);
|
|
238
|
+
cursor: pointer;
|
|
239
|
+
font-size: 12px;
|
|
240
|
+
text-decoration: underline;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/* Dialog footer actions. */
|
|
244
|
+
.dww-actions {
|
|
245
|
+
display: flex;
|
|
246
|
+
align-items: center;
|
|
247
|
+
justify-content: flex-end;
|
|
248
|
+
gap: 8px;
|
|
249
|
+
padding: 14px 20px 0;
|
|
250
|
+
}
|
|
251
|
+
.dww-btn {
|
|
252
|
+
height: 36px;
|
|
253
|
+
padding: 0 14px;
|
|
254
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
255
|
+
border-radius: 8px;
|
|
256
|
+
background: transparent;
|
|
257
|
+
color: var(--dsw-alias-label-primary);
|
|
258
|
+
cursor: pointer;
|
|
259
|
+
font-size: 14px;
|
|
260
|
+
}
|
|
261
|
+
.dww-btn:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }
|
|
262
|
+
.dww-btn--primary {
|
|
263
|
+
border-color: transparent;
|
|
264
|
+
background: var(--dsw-alias-button-primary-fill);
|
|
265
|
+
color: var(--dsw-alias-label-primary-foreground);
|
|
266
|
+
}
|
|
267
|
+
.dww-btn--primary:hover:not(:disabled) { background: var(--dsw-alias-button-primary-hover); }
|
|
268
|
+
.dww-btn:disabled { cursor: default; opacity: 0.6; }
|
|
269
|
+
`
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Idempotently inject the plugin stylesheet. No-op when a tag with the
|
|
273
|
+
* plugin's data attribute already exists.
|
|
274
|
+
*/
|
|
275
|
+
export function ensureStyles(): void {
|
|
276
|
+
if (typeof document === 'undefined') return
|
|
277
|
+
if (document.querySelector(`style[${STYLE_TAG_DATA_ATTRIBUTE}]`) !== null) return
|
|
278
|
+
const style = document.createElement('style')
|
|
279
|
+
style.setAttribute('data-plugin', 'dsh-wsl-workspace')
|
|
280
|
+
style.textContent = STYLES
|
|
281
|
+
document.head.appendChild(style)
|
|
282
|
+
}
|