Skip to content

Commit 85e7933

Browse files
feat: 支持通过 SSH 关联远程开发资源
- 支持保存并测试 SSH Profile\n- 支持关联远程 design system 与附加远程文件\n- 支持将当前预览 HTML 推送到 SSH 服务器\n- 补充共享类型、IPC、store 与前端入口\n- 调整 Advanced 页面 SSH 区块文案与分界线表现 Signed-off-by: Sun-sunshine06 <Sun-sunshine06@users.noreply.github.com>
1 parent 1d2d0ef commit 85e7933

29 files changed

Lines changed: 1871 additions & 90 deletions

apps/desktop/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,15 @@
2727
"@open-codesign/shared": "workspace:*",
2828
"@open-codesign/templates": "workspace:*",
2929
"@open-codesign/ui": "workspace:*",
30+
"@types/ssh2": "^1.15.5",
3031
"better-sqlite3": "^12.9.0",
3132
"electron-log": "^5",
3233
"electron-updater": "^6.3.9",
3334
"lucide-react": "^1.8.0",
3435
"react": "^19.0.0",
3536
"react-dom": "^19.0.0",
3637
"smol-toml": "^1.6.1",
38+
"ssh2": "^1.17.0",
3739
"zip-lib": "^1.0.4",
3840
"zustand": "^5.0.2"
3941
},

apps/desktop/src/main/codex-oauth-ipc.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ async function persistProviderMutation(
108108
activeModel: cfg?.activeModel ?? '',
109109
secrets: cfg?.secrets ?? {},
110110
providers: nextProviders,
111+
sshProfiles: cfg?.sshProfiles ?? {},
111112
...(cfg?.designSystem !== undefined ? { designSystem: cfg.designSystem } : {}),
112113
});
113114
await writeConfig(next);
@@ -130,6 +131,7 @@ async function claimActiveProviderIfUnset(): Promise<void> {
130131
activeModel: CHATGPT_CODEX_PROVIDER.defaultModel,
131132
secrets: cfg.secrets,
132133
providers: cfg.providers,
134+
sshProfiles: cfg.sshProfiles ?? {},
133135
...(cfg.designSystem !== undefined ? { designSystem: cfg.designSystem } : {}),
134136
});
135137
await writeConfig(next);

apps/desktop/src/main/design-system.ts

Lines changed: 52 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
type StoredDesignSystem,
77
} from '@open-codesign/shared';
88

9-
const IGNORED_DIRS = new Set([
9+
export const IGNORED_DESIGN_SYSTEM_DIRS = new Set([
1010
'.git',
1111
'.idea',
1212
'.next',
@@ -19,7 +19,7 @@ const IGNORED_DIRS = new Set([
1919
'out',
2020
]);
2121

22-
const CANDIDATE_EXTS = new Set([
22+
export const DESIGN_SYSTEM_CANDIDATE_EXTS = new Set([
2323
'.css',
2424
'.scss',
2525
'.sass',
@@ -53,6 +53,11 @@ interface CandidateFile {
5353
score: number;
5454
}
5555

56+
export interface DesignSystemSourceFile {
57+
relativePath: string;
58+
content: string;
59+
}
60+
5661
function pushUnique(target: string[], value: string, max: number): void {
5762
if (!value || target.includes(value) || target.length >= max) return;
5863
target.push(value);
@@ -65,7 +70,7 @@ function cleanValue(value: string): string {
6570
.trim();
6671
}
6772

68-
function scoreCandidate(relativePath: string): number {
73+
export function scoreDesignSystemCandidate(relativePath: string): number {
6974
const fileName = basename(relativePath);
7075
let score = 1;
7176
for (const pattern of PRIORITY_PATTERNS) {
@@ -77,6 +82,11 @@ function scoreCandidate(relativePath: string): number {
7782
return score;
7883
}
7984

85+
export function isDesignSystemCandidateFile(fileName: string): boolean {
86+
const extension = extname(fileName).toLowerCase();
87+
return DESIGN_SYSTEM_CANDIDATE_EXTS.has(extension) || /tailwind\.config/i.test(fileName);
88+
}
89+
8090
async function collectCandidateFiles(
8191
rootPath: string,
8292
dirPath: string,
@@ -95,16 +105,15 @@ async function collectCandidateFiles(
95105
if (files.length >= MAX_FILES) return;
96106
const fullPath = join(dirPath, entry.name);
97107
if (entry.isDirectory()) {
98-
if (!IGNORED_DIRS.has(entry.name)) {
108+
if (!IGNORED_DESIGN_SYSTEM_DIRS.has(entry.name)) {
99109
await collectCandidateFiles(rootPath, fullPath, files);
100110
}
101111
continue;
102112
}
103113
if (!entry.isFile()) continue;
104-
const extension = extname(entry.name).toLowerCase();
105-
if (!CANDIDATE_EXTS.has(extension) && !/tailwind\.config/i.test(entry.name)) continue;
114+
if (!isDesignSystemCandidateFile(entry.name)) continue;
106115
const relativePath = relative(rootPath, fullPath).replace(/\\/g, '/');
107-
files.push({ fullPath, relativePath, score: scoreCandidate(relativePath) });
116+
files.push({ fullPath, relativePath, score: scoreDesignSystemCandidate(relativePath) });
108117
}
109118
}
110119

@@ -184,35 +193,33 @@ function buildSummary(
184193
return parts.join(' ');
185194
}
186195

187-
export async function scanDesignSystem(rootPath: string): Promise<StoredDesignSystem> {
188-
const candidates: CandidateFile[] = [];
189-
await collectCandidateFiles(rootPath, rootPath, candidates);
190-
191-
const selected = candidates
192-
.sort((a, b) => b.score - a.score || a.relativePath.localeCompare(b.relativePath))
193-
.slice(0, MAX_SELECTED_FILES);
194-
196+
export function buildDesignSystemSnapshot(
197+
rootPath: string,
198+
files: DesignSystemSourceFile[],
199+
extra: Partial<
200+
Pick<StoredDesignSystem, 'sourceKind' | 'sshProfileId' | 'sshHost' | 'sshPort' | 'sshUsername'>
201+
> = {},
202+
): StoredDesignSystem {
195203
const colors: string[] = [];
196204
const fonts: string[] = [];
197205
const spacing: string[] = [];
198206
const radius: string[] = [];
199207
const shadows: string[] = [];
200208

201-
for (const file of selected) {
202-
let raw = '';
203-
try {
204-
raw = await readFile(file.fullPath, 'utf8');
205-
} catch {
206-
continue;
207-
}
208-
const snippet = raw.slice(0, MAX_FILE_CHARS);
209+
for (const file of files) {
210+
const snippet = file.content.slice(0, MAX_FILE_CHARS);
209211
collectCssVarValues(snippet, colors, spacing, radius, shadows);
210212
collectLooseValues(snippet, colors, fonts, spacing, radius, shadows);
211213
}
212214

213215
const baseSnapshot = {
214216
rootPath,
215-
sourceFiles: selected.map((file) => file.relativePath),
217+
sourceKind: extra.sourceKind ?? 'local',
218+
...(extra.sshProfileId !== undefined ? { sshProfileId: extra.sshProfileId } : {}),
219+
...(extra.sshHost !== undefined ? { sshHost: extra.sshHost } : {}),
220+
...(extra.sshPort !== undefined ? { sshPort: extra.sshPort } : {}),
221+
...(extra.sshUsername !== undefined ? { sshUsername: extra.sshUsername } : {}),
222+
sourceFiles: files.map((file) => file.relativePath),
216223
colors,
217224
fonts,
218225
spacing,
@@ -227,3 +234,24 @@ export async function scanDesignSystem(rootPath: string): Promise<StoredDesignSy
227234
extractedAt: new Date().toISOString(),
228235
};
229236
}
237+
238+
export async function scanDesignSystem(rootPath: string): Promise<StoredDesignSystem> {
239+
const candidates: CandidateFile[] = [];
240+
await collectCandidateFiles(rootPath, rootPath, candidates);
241+
242+
const selected = candidates
243+
.sort((a, b) => b.score - a.score || a.relativePath.localeCompare(b.relativePath))
244+
.slice(0, MAX_SELECTED_FILES);
245+
246+
const files: DesignSystemSourceFile[] = [];
247+
for (const file of selected) {
248+
try {
249+
files.push({
250+
relativePath: file.relativePath,
251+
content: await readFile(file.fullPath, 'utf8'),
252+
});
253+
} catch {}
254+
}
255+
256+
return buildDesignSystemSnapshot(rootPath, files);
257+
}

apps/desktop/src/main/index.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import { resolveActiveModel } from './provider-settings';
5353
import { withRun } from './runContext';
5454
import { safeInitSnapshotsDb } from './snapshots-db';
5555
import { registerSnapshotsIpc, registerSnapshotsUnavailableIpc } from './snapshots-ipc';
56+
import { createRemoteAttachment, exportToRemote, scanRemoteDesignSystem } from './ssh-remote';
5657
import { initStorageSettings } from './storage-settings';
5758

5859
// ESM shim: package.json "type": "module" means the built bundle is ESM and
@@ -416,6 +417,20 @@ function registerIpcHandlers(): void {
416417
);
417418
});
418419

420+
ipcMain.handle('remote:v1:attach-file', async (_e, raw: unknown) => {
421+
if (typeof raw !== 'object' || raw === null) {
422+
throw new CodesignError('remote:v1:attach-file expects an object payload', 'IPC_BAD_INPUT');
423+
}
424+
const r = raw as Record<string, unknown>;
425+
if (typeof r['profileId'] !== 'string' || r['profileId'].trim().length === 0) {
426+
throw new CodesignError('profileId must be a non-empty string', 'IPC_BAD_INPUT');
427+
}
428+
if (typeof r['path'] !== 'string' || r['path'].trim().length === 0) {
429+
throw new CodesignError('path must be a non-empty string', 'IPC_BAD_INPUT');
430+
}
431+
return createRemoteAttachment(r['profileId'].trim(), r['path'].trim());
432+
});
433+
419434
ipcMain.handle('codesign:pick-design-system-directory', async () => {
420435
const result = mainWindow
421436
? await dialog.showOpenDialog(mainWindow, {
@@ -439,12 +454,71 @@ function registerIpcHandlers(): void {
439454
return nextState;
440455
});
441456

457+
ipcMain.handle('remote:v1:link-design-system', async (_e, raw: unknown) => {
458+
if (typeof raw !== 'object' || raw === null) {
459+
throw new CodesignError(
460+
'remote:v1:link-design-system expects an object payload',
461+
'IPC_BAD_INPUT',
462+
);
463+
}
464+
const r = raw as Record<string, unknown>;
465+
if (typeof r['profileId'] !== 'string' || r['profileId'].trim().length === 0) {
466+
throw new CodesignError('profileId must be a non-empty string', 'IPC_BAD_INPUT');
467+
}
468+
if (typeof r['path'] !== 'string' || r['path'].trim().length === 0) {
469+
throw new CodesignError('path must be a non-empty string', 'IPC_BAD_INPUT');
470+
}
471+
const profileId = r['profileId'].trim();
472+
const rootPath = r['path'].trim();
473+
logIpc.info('designSystem.ssh.scan.start', { profileId, rootPath });
474+
const snapshot = await scanRemoteDesignSystem(profileId, rootPath);
475+
const nextState = await setDesignSystem(snapshot);
476+
logIpc.info('designSystem.ssh.scan.ok', {
477+
profileId,
478+
rootPath: snapshot.rootPath,
479+
sourceFiles: snapshot.sourceFiles.length,
480+
colors: snapshot.colors.length,
481+
fonts: snapshot.fonts.length,
482+
});
483+
return nextState;
484+
});
485+
442486
ipcMain.handle('codesign:clear-design-system', async () => {
443487
const nextState = await setDesignSystem(null);
444488
logIpc.info('designSystem.clear');
445489
return nextState;
446490
});
447491

492+
ipcMain.handle('remote:v1:export', async (_e, raw: unknown) => {
493+
if (typeof raw !== 'object' || raw === null) {
494+
throw new CodesignError('remote:v1:export expects an object payload', 'IPC_BAD_INPUT');
495+
}
496+
const r = raw as Record<string, unknown>;
497+
const format = r['format'];
498+
const htmlContent = r['htmlContent'];
499+
const profileId = r['profileId'];
500+
const remotePath = r['remotePath'];
501+
if (
502+
format !== 'html' &&
503+
format !== 'pdf' &&
504+
format !== 'pptx' &&
505+
format !== 'zip' &&
506+
format !== 'markdown'
507+
) {
508+
throw new CodesignError(`Unknown export format: ${String(format)}`, 'IPC_BAD_INPUT');
509+
}
510+
if (typeof htmlContent !== 'string' || htmlContent.length === 0) {
511+
throw new CodesignError('htmlContent must be a non-empty string', 'IPC_BAD_INPUT');
512+
}
513+
if (typeof profileId !== 'string' || profileId.trim().length === 0) {
514+
throw new CodesignError('profileId must be a non-empty string', 'IPC_BAD_INPUT');
515+
}
516+
if (typeof remotePath !== 'string' || remotePath.trim().length === 0) {
517+
throw new CodesignError('remotePath must be a non-empty string', 'IPC_BAD_INPUT');
518+
}
519+
return exportToRemote(profileId.trim(), remotePath.trim(), format, htmlContent);
520+
});
521+
448522
ipcMain.handle('codesign:v1:generate', async (_e, raw: unknown) => {
449523
const payload = GeneratePayloadV1.parse(raw);
450524
const id = payload.generationId;

apps/desktop/src/main/onboarding-ipc.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,11 @@ vi.mock('./imports/claude-code-config', () => ({
103103
readClaudeCodeSettings: vi.fn(async () => null),
104104
}));
105105

106+
vi.mock('./ssh-remote', () => ({
107+
testSshConnection: vi.fn(async () => undefined),
108+
testSavedSshProfile: vi.fn(async () => undefined),
109+
}));
110+
106111
vi.mock('@open-codesign/providers', () => ({
107112
pingProvider: vi.fn(async () => ({ ok: true, modelCount: 1 })),
108113
}));
@@ -244,6 +249,7 @@ describe('getApiKeyForProvider — API key retrieval', () => {
244249
defaultModel: 'claude-sonnet-4-6',
245250
},
246251
},
252+
sshProfiles: {},
247253
provider: 'anthropic',
248254
modelPrimary: 'claude-sonnet-4-6',
249255
baseUrls: {},
@@ -384,6 +390,7 @@ describe('config:v1:import-claude-code-config — user-type branching', () => {
384390
defaultModel: 'claude-sonnet-4-6',
385391
},
386392
},
393+
sshProfiles: {},
387394
provider: 'anthropic',
388395
modelPrimary: 'claude-sonnet-4-6',
389396
baseUrls: {},
@@ -478,6 +485,7 @@ describe('getApiKeyForProvider — envKey runtime fallback', () => {
478485
envKey: ENV_NAME,
479486
},
480487
},
488+
sshProfiles: {},
481489
provider: 'fallback-test',
482490
modelPrimary: 'x',
483491
baseUrls: {},
@@ -509,6 +517,7 @@ describe('getApiKeyForProvider — envKey runtime fallback', () => {
509517
envKey: ENV_NAME,
510518
},
511519
},
520+
sshProfiles: {},
512521
provider: 'no-key',
513522
modelPrimary: 'x',
514523
baseUrls: {},

0 commit comments

Comments
 (0)