mirror of
https://gitee.com/tuxmonkey/Helix
synced 2026-10-23 10:42:20 +08:00
Initial commit: Helix Git GUI with Apple design, Electron 43, i18n support
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
out/
|
||||
*.exe
|
||||
*.blockmap
|
||||
builder-debug.yml
|
||||
50
README.md
Normal file
50
README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Helix
|
||||
|
||||
> 一款 Apple 设计风格的 Git 仓库可视化管理工具,基于 Electron 构建。
|
||||
|
||||
## 特性
|
||||
|
||||
- 可视化提交历史、分支管理、工作区变更
|
||||
- Stage / Unstage / Commit 工作流
|
||||
- 分支创建与切换
|
||||
- Fetch / Pull / Push 远程同步
|
||||
- 中文 / English 双语界面,一键切换
|
||||
- MiSans 中文字体优先,自动回退系统字体
|
||||
- 浅色 / 深色双主题,跟随系统
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **Electron** 43 — 跨平台桌面框架
|
||||
- **Vanilla JS** — 零框架依赖
|
||||
- **Apple Design** — 弹簧动画、半透明材质、SF Pro / MiSans 字体体系
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
# Windows 安装包
|
||||
npm run build
|
||||
|
||||
# Windows 便携版
|
||||
npm run build:portable
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
Helix/
|
||||
├── main.js # Electron 主进程,Git 操作
|
||||
├── preload.js # 安全 IPC 桥接
|
||||
├── package.json
|
||||
└── src/
|
||||
├── index.html # 界面布局
|
||||
├── styles.css # Apple 设计系统样式
|
||||
├── i18n.js # 中英文翻译
|
||||
└── renderer.js # 渲染逻辑
|
||||
```
|
||||
171
main.js
Normal file
171
main.js
Normal file
@@ -0,0 +1,171 @@
|
||||
const { app, BrowserWindow, ipcMain, dialog } = require('electron');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { exec } = require('child_process');
|
||||
|
||||
let mainWindow;
|
||||
const settingsPath = path.join(app.getPath('userData'), 'settings.json');
|
||||
|
||||
function loadSettings() {
|
||||
try {
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
return JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
return { language: 'zh-CN' };
|
||||
}
|
||||
|
||||
function saveSettings(settings) {
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
frame: false,
|
||||
backgroundColor: '#f5f5f7',
|
||||
show: false,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
mainWindow.show();
|
||||
});
|
||||
|
||||
mainWindow.loadFile('src/index.html');
|
||||
}
|
||||
|
||||
function runGitCommand(repoPath, args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
exec(`git ${args.join(' ')}`, { cwd: repoPath, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
reject({ message: err.message, stderr: stderr || '' });
|
||||
} else {
|
||||
resolve(stdout.trim());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ipcMain.handle('settings:load', () => loadSettings());
|
||||
ipcMain.handle('settings:save', (event, settings) => saveSettings(settings));
|
||||
|
||||
ipcMain.handle('git:log', async (event, repoPath) => {
|
||||
try {
|
||||
const format = '--format=%H||%h||%an||%ae||%ad||%s||%D||%P';
|
||||
const output = await runGitCommand(repoPath, ['log', '-50', format, '--date=relative', '--all', '--topo-order']);
|
||||
if (!output) return [];
|
||||
return output.split('\n').map(line => {
|
||||
const [hash, shortHash, author, email, date, subject, refs, parents] = line.split('||');
|
||||
return { hash, shortHash, author, email, date, subject, refs, parents };
|
||||
});
|
||||
} catch (e) { return []; }
|
||||
});
|
||||
|
||||
ipcMain.handle('git:status', async (event, repoPath) => {
|
||||
try {
|
||||
const output = await runGitCommand(repoPath, ['status', '--porcelain', '-b']);
|
||||
const lines = output ? output.split('\n') : [];
|
||||
const branchLine = lines[0] || '';
|
||||
const branch = branchLine.replace('## ', '').split('...')[0].replace(/ \[.*\]/, '').trim();
|
||||
const files = lines.slice(1).filter(Boolean).map(line => ({
|
||||
status: line.substring(0, 2).trim(),
|
||||
filename: line.substring(3).trim()
|
||||
}));
|
||||
return { branch, files };
|
||||
} catch (e) { return { branch: '', files: [] }; }
|
||||
});
|
||||
|
||||
ipcMain.handle('git:branches', async (event, repoPath) => {
|
||||
try {
|
||||
const output = await runGitCommand(repoPath, ['branch', '--format=%(refname:short)||%(objectname:short)||%(upstream:short)||%(committerdate:relative)']);
|
||||
if (!output) return [];
|
||||
return output.split('\n').map(line => {
|
||||
const isCurrent = line.startsWith('*');
|
||||
const clean = line.replace(/^\* /, '');
|
||||
const [name, hash, upstream, date] = clean.split('||');
|
||||
return { name, hash, upstream, date, isCurrent };
|
||||
});
|
||||
} catch (e) { return []; }
|
||||
});
|
||||
|
||||
ipcMain.handle('git:diff', async (event, repoPath, hash) => {
|
||||
try {
|
||||
const args = hash ? ['show', '--stat', '--format=', hash] : ['diff', '--stat', 'HEAD'];
|
||||
return await runGitCommand(repoPath, args);
|
||||
} catch (e) { return ''; }
|
||||
});
|
||||
|
||||
ipcMain.handle('git:unstaged-diff', async (event, repoPath) => {
|
||||
try { return await runGitCommand(repoPath, ['diff', '--stat']); } catch (e) { return ''; }
|
||||
});
|
||||
|
||||
ipcMain.handle('git:staged-diff', async (event, repoPath) => {
|
||||
try { return await runGitCommand(repoPath, ['diff', '--cached', '--stat']); } catch (e) { return ''; }
|
||||
});
|
||||
|
||||
ipcMain.handle('git:stage', async (event, repoPath, files) => {
|
||||
return runGitCommand(repoPath, ['add', ...files]);
|
||||
});
|
||||
|
||||
ipcMain.handle('git:unstage', async (event, repoPath, files) => {
|
||||
return runGitCommand(repoPath, ['reset', 'HEAD', '--', ...files]);
|
||||
});
|
||||
|
||||
ipcMain.handle('git:commit', async (event, repoPath, message) => {
|
||||
return runGitCommand(repoPath, ['commit', '-m', message]);
|
||||
});
|
||||
|
||||
ipcMain.handle('git:checkout', async (event, repoPath, branch) => {
|
||||
return runGitCommand(repoPath, ['checkout', branch]);
|
||||
});
|
||||
|
||||
ipcMain.handle('git:create-branch', async (event, repoPath, name) => {
|
||||
return runGitCommand(repoPath, ['branch', name]);
|
||||
});
|
||||
|
||||
ipcMain.handle('git:pull', async (event, repoPath) => {
|
||||
return runGitCommand(repoPath, ['pull']);
|
||||
});
|
||||
|
||||
ipcMain.handle('git:push', async (event, repoPath) => {
|
||||
return runGitCommand(repoPath, ['push']);
|
||||
});
|
||||
|
||||
ipcMain.handle('git:fetch', async (event, repoPath) => {
|
||||
return runGitCommand(repoPath, ['fetch', '--all']);
|
||||
});
|
||||
|
||||
ipcMain.handle('git:discard', async (event, repoPath, file) => {
|
||||
return runGitCommand(repoPath, ['checkout', '--', file]);
|
||||
});
|
||||
|
||||
ipcMain.handle('dialog:open-repo', async () => {
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
properties: ['openDirectory'],
|
||||
title: 'Open Git Repository'
|
||||
});
|
||||
if (result.canceled || result.filePaths.length === 0) return null;
|
||||
return result.filePaths[0];
|
||||
});
|
||||
|
||||
ipcMain.handle('window:minimize', () => mainWindow.minimize());
|
||||
ipcMain.handle('window:maximize', () => {
|
||||
if (mainWindow.isMaximized()) mainWindow.unmaximize();
|
||||
else mainWindow.maximize();
|
||||
});
|
||||
ipcMain.handle('window:close', () => mainWindow.close());
|
||||
|
||||
app.whenReady().then(createWindow);
|
||||
app.on('window-all-closed', () => app.quit());
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
3583
package-lock.json
generated
Normal file
3583
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
package.json
Normal file
49
package.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "Helix",
|
||||
"version": "1.0.0",
|
||||
"description": "A visual Git repository manager with Apple-inspired design",
|
||||
"main": "main.js",
|
||||
"author": "Helix",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"dev": "electron . --dev",
|
||||
"build": "electron-builder --win",
|
||||
"build:portable": "electron-builder --win portable"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.helix.git",
|
||||
"productName": "Helix",
|
||||
"directories": {
|
||||
"output": "dist"
|
||||
},
|
||||
"files": [
|
||||
"main.js",
|
||||
"preload.js",
|
||||
"src/**/*",
|
||||
"node_modules/**/*"
|
||||
],
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": ["x64"]
|
||||
}
|
||||
],
|
||||
"icon": null
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"installerIcon": null,
|
||||
"uninstallerIcon": null,
|
||||
"deleteAppDataOnUninstall": true
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^43.2.0",
|
||||
"electron-builder": "^26.15.3"
|
||||
}
|
||||
}
|
||||
25
preload.js
Normal file
25
preload.js
Normal file
@@ -0,0 +1,25 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
contextBridge.exposeInMainWorld('helix', {
|
||||
getLog: (repoPath) => ipcRenderer.invoke('git:log', repoPath),
|
||||
getStatus: (repoPath) => ipcRenderer.invoke('git:status', repoPath),
|
||||
getBranches: (repoPath) => ipcRenderer.invoke('git:branches', repoPath),
|
||||
getDiff: (repoPath, hash) => ipcRenderer.invoke('git:diff', repoPath, hash),
|
||||
getUnstagedDiff: (repoPath) => ipcRenderer.invoke('git:unstaged-diff', repoPath),
|
||||
getStagedDiff: (repoPath) => ipcRenderer.invoke('git:staged-diff', repoPath),
|
||||
stage: (repoPath, files) => ipcRenderer.invoke('git:stage', repoPath, files),
|
||||
unstage: (repoPath, files) => ipcRenderer.invoke('git:unstage', repoPath, files),
|
||||
commit: (repoPath, message) => ipcRenderer.invoke('git:commit', repoPath, message),
|
||||
checkout: (repoPath, branch) => ipcRenderer.invoke('git:checkout', repoPath, branch),
|
||||
createBranch: (repoPath, name) => ipcRenderer.invoke('git:create-branch', repoPath, name),
|
||||
pull: (repoPath) => ipcRenderer.invoke('git:pull', repoPath),
|
||||
push: (repoPath) => ipcRenderer.invoke('git:push', repoPath),
|
||||
fetch: (repoPath) => ipcRenderer.invoke('git:fetch', repoPath),
|
||||
discard: (repoPath, file) => ipcRenderer.invoke('git:discard', repoPath, file),
|
||||
openRepo: () => ipcRenderer.invoke('dialog:open-repo'),
|
||||
loadSettings: () => ipcRenderer.invoke('settings:load'),
|
||||
saveSettings: (settings) => ipcRenderer.invoke('settings:save', settings),
|
||||
minimize: () => ipcRenderer.invoke('window:minimize'),
|
||||
maximize: () => ipcRenderer.invoke('window:maximize'),
|
||||
close: () => ipcRenderer.invoke('window:close')
|
||||
});
|
||||
169
src/i18n.js
Normal file
169
src/i18n.js
Normal file
@@ -0,0 +1,169 @@
|
||||
window._ = (() => {
|
||||
const langs = {
|
||||
'zh-CN': {
|
||||
appTitle: 'Helix',
|
||||
sidebarRepos: '仓库',
|
||||
sectionBranches: '分支',
|
||||
sectionWorkingDir: '工作区',
|
||||
openRepo: '打开仓库',
|
||||
noBranches: '打开仓库以查看分支',
|
||||
newBranch: '+ 新建分支',
|
||||
fetch: '获取',
|
||||
pull: '拉取',
|
||||
push: '推送',
|
||||
cleanTree: '工作区干净',
|
||||
noChanges: '没有变更',
|
||||
commitHistory: '提交历史',
|
||||
changes: '变更',
|
||||
unstaged: '未暂存',
|
||||
staged: '已暂存',
|
||||
diff: '差异',
|
||||
selectDetail: '选择提交或文件查看详情',
|
||||
refresh: '刷新',
|
||||
commit: '提交',
|
||||
welcomeTitle: '欢迎使用 Helix',
|
||||
welcomeDesc: '打开 Git 仓库以可视化提交历史、管理分支并查看变更 — 一切尽在流畅的界面中。',
|
||||
ready: '就绪',
|
||||
refreshing: '刷新中...',
|
||||
on: 'on',
|
||||
switchedTo: '已切换到',
|
||||
createdBranch: '已创建分支',
|
||||
stagedF: '已暂存',
|
||||
unstagedF: '已取消暂存',
|
||||
discardedF: '已丢弃',
|
||||
committed: '已提交',
|
||||
fetched: '已获取',
|
||||
pulled: '已拉取',
|
||||
pushed: '已推送',
|
||||
failedStatus: '加载状态失败',
|
||||
failedBranches: '加载分支失败',
|
||||
failedCommits: '加载提交失败',
|
||||
failedDiff: '加载差异失败',
|
||||
failedStage: '暂存失败',
|
||||
failedUnstage: '取消暂存失败',
|
||||
failedDiscard: '丢弃失败',
|
||||
failedCommit: '提交失败',
|
||||
failedFetch: '获取失败',
|
||||
failedPull: '拉取失败',
|
||||
failedPush: '推送失败',
|
||||
failedSwitch: '切换失败',
|
||||
failedCreateBranch: '创建分支失败',
|
||||
createBranch: '新建分支',
|
||||
createBranchDesc: '从当前分支创建新分支。',
|
||||
branchName: '分支名称',
|
||||
cancel: '取消',
|
||||
create: '创建',
|
||||
commitChanges: '提交更改',
|
||||
commitDesc: '使用已暂存的更改创建提交。',
|
||||
commitMessage: '提交信息',
|
||||
discardTitle: '丢弃更改',
|
||||
discardDesc: '确定要丢弃对',
|
||||
discardDesc2: '的更改吗?此操作无法撤销。',
|
||||
discardBtn: '丢弃',
|
||||
stageBtn: '暂存',
|
||||
unstageBtn: '取消暂存',
|
||||
discardFileBtn: '丢弃',
|
||||
settings: '设置',
|
||||
language: '语言',
|
||||
systemDefault: '系统默认字体',
|
||||
cnFont: 'MiSans(中文字体)',
|
||||
languageLabel: '界面语言',
|
||||
themeLabel: '主题',
|
||||
followSystem: '跟随系统'
|
||||
},
|
||||
'en': {
|
||||
appTitle: 'Helix',
|
||||
sidebarRepos: 'Repositories',
|
||||
sectionBranches: 'Branches',
|
||||
sectionWorkingDir: 'Working Directory',
|
||||
openRepo: 'Open Repository',
|
||||
noBranches: 'Open a repository to view branches',
|
||||
newBranch: '+ New Branch',
|
||||
fetch: 'Fetch',
|
||||
pull: 'Pull',
|
||||
push: 'Push',
|
||||
cleanTree: 'Working tree clean',
|
||||
noChanges: 'No changes',
|
||||
commitHistory: 'Commit History',
|
||||
changes: 'Changes',
|
||||
unstaged: 'Unstaged',
|
||||
staged: 'Staged',
|
||||
diff: 'Diff',
|
||||
selectDetail: 'Select a commit or file to view details',
|
||||
refresh: 'Refresh',
|
||||
commit: 'Commit',
|
||||
welcomeTitle: 'Welcome to Helix',
|
||||
welcomeDesc: 'Open a Git repository to visualize your commit history, manage branches, and review changes — all in one fluid interface.',
|
||||
ready: 'Ready',
|
||||
refreshing: 'Refreshing...',
|
||||
on: 'on',
|
||||
switchedTo: 'Switched to',
|
||||
createdBranch: 'Created branch',
|
||||
stagedF: 'Staged',
|
||||
unstagedF: 'Unstaged',
|
||||
discardedF: 'Discarded',
|
||||
committed: 'Committed',
|
||||
fetched: 'Fetched',
|
||||
pulled: 'Pulled',
|
||||
pushed: 'Pushed',
|
||||
failedStatus: 'Failed to load status',
|
||||
failedBranches: 'Failed to load branches',
|
||||
failedCommits: 'Failed to load commits',
|
||||
failedDiff: 'Failed to load diff',
|
||||
failedStage: 'Failed to stage',
|
||||
failedUnstage: 'Failed to unstage',
|
||||
failedDiscard: 'Failed to discard',
|
||||
failedCommit: 'Commit failed',
|
||||
failedFetch: 'Fetch failed',
|
||||
failedPull: 'Pull failed',
|
||||
failedPush: 'Push failed',
|
||||
failedSwitch: 'Failed to switch',
|
||||
failedCreateBranch: 'Failed to create branch',
|
||||
createBranch: 'Create Branch',
|
||||
createBranchDesc: 'Create a new branch from the current branch.',
|
||||
branchName: 'Branch name',
|
||||
cancel: 'Cancel',
|
||||
create: 'Create',
|
||||
commitChanges: 'Commit Changes',
|
||||
commitDesc: 'Create a commit with your staged changes.',
|
||||
commitMessage: 'Commit message',
|
||||
discardTitle: 'Discard Changes',
|
||||
discardDesc: 'Are you sure you want to discard changes to',
|
||||
discardDesc2: '? This cannot be undone.',
|
||||
discardBtn: 'Discard',
|
||||
stageBtn: 'Stage',
|
||||
unstageBtn: 'Unstage',
|
||||
discardFileBtn: 'Discard',
|
||||
settings: 'Settings',
|
||||
language: 'Language',
|
||||
systemDefault: 'System Default Font',
|
||||
cnFont: 'MiSans (Chinese Font)',
|
||||
languageLabel: 'Interface Language',
|
||||
themeLabel: 'Theme',
|
||||
followSystem: 'Follow System'
|
||||
}
|
||||
};
|
||||
|
||||
let currentLang = 'zh-CN';
|
||||
|
||||
function t(key) {
|
||||
return langs[currentLang]?.[key] || langs['en']?.[key] || key;
|
||||
}
|
||||
|
||||
function setLang(lang) {
|
||||
if (langs[lang]) {
|
||||
currentLang = lang;
|
||||
document.documentElement.lang = lang;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getLang() {
|
||||
return currentLang;
|
||||
}
|
||||
|
||||
return { t, setLang, getLang, langs };
|
||||
})();
|
||||
|
||||
window.i18nReady = true;
|
||||
144
src/index.html
Normal file
144
src/index.html
Normal file
@@ -0,0 +1,144 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Helix</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="title-bar">
|
||||
<div class="title-bar-drag"></div>
|
||||
<div class="title-bar-title">Helix</div>
|
||||
<div class="title-bar-controls">
|
||||
<button class="title-btn" id="btn-minimize" title="Minimize">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12"><rect x="1" y="5.5" width="10" height="1" fill="currentColor"/></svg>
|
||||
</button>
|
||||
<button class="title-btn" id="btn-maximize" title="Maximize">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12"><rect x="1.5" y="1.5" width="9" height="9" rx="1" fill="none" stroke="currentColor"/></svg>
|
||||
</button>
|
||||
<button class="title-btn title-btn-close" id="btn-close" title="Close">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12"><path d="M1 1l10 10M11 1L1 11" stroke="currentColor" stroke-width="1.2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="app">
|
||||
<section id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<button id="open-repo-btn" class="btn-icon" title="打开仓库">
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
||||
<path d="M15.5 5.5H5.5C4.95 5.5 4.5 5.95 4.5 6.5V14.5C4.5 15.05 4.95 15.5 5.5 15.5H13.5C14.05 15.5 14.5 15.05 14.5 14.5V2.5C14.5 1.95 14.05 1.5 13.5 1.5H2.5C1.95 1.5 1.5 1.95 1.5 2.5V14.5C1.5 15.05 1.95 15.5 2.5 15.5"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="sidebar-title" data-i18n="sidebarRepos">仓库</span>
|
||||
<button id="settings-btn" class="btn-icon btn-settings" title="设置">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round">
|
||||
<circle cx="8" cy="8" r="2.5"/>
|
||||
<path d="M8 1.5v1M8 13.5v1M1.5 8h1M13.5 8h1M3.4 3.4l.7.7M11.9 11.9l.7.7M3.4 12.6l.7-.7M11.9 4.1l.7-.7"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="repo-info" class="repo-info hidden">
|
||||
<div class="repo-name" id="repo-name"></div>
|
||||
<div class="repo-branch" id="repo-branch"></div>
|
||||
</div>
|
||||
<div class="sidebar-section">
|
||||
<div class="section-label" data-i18n="sectionBranches">分支</div>
|
||||
<div id="branch-list" class="branch-list">
|
||||
<div class="empty-state" data-i18n="noBranches">打开仓库以查看分支</div>
|
||||
</div>
|
||||
<button id="new-branch-btn" class="btn-secondary hidden" data-i18n="newBranch">+ 新建分支</button>
|
||||
</div>
|
||||
<div class="sidebar-section">
|
||||
<div class="section-label" data-i18n="sectionWorkingDir">工作区</div>
|
||||
<div id="status-summary" class="status-summary">
|
||||
<div class="empty-state" data-i18n="cleanTree">工作区干净</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-actions">
|
||||
<button id="fetch-btn" class="btn-secondary hidden" data-i18n="fetch">获取</button>
|
||||
<button id="pull-btn" class="btn-secondary hidden" data-i18n="pull">拉取</button>
|
||||
<button id="push-btn" class="btn-secondary hidden" data-i18n="push">推送</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main id="main-content">
|
||||
<header id="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<button id="refresh-btn" class="btn-icon" title="刷新">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
||||
<path d="M14 8a6 6 0 0 1-6 6 6 6 0 0 1-6-6 6 6 0 0 1 6-6"/>
|
||||
<polyline points="10,1 14,1 14,5"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="toolbar-title" id="toolbar-title">Helix</div>
|
||||
</div>
|
||||
<div class="toolbar-right" id="toolbar-actions"></div>
|
||||
</header>
|
||||
|
||||
<div id="content-area">
|
||||
<div id="commit-section" class="panel">
|
||||
<div class="panel-header">
|
||||
<span data-i18n="commitHistory">提交历史</span>
|
||||
<span class="badge" id="commit-count">0</span>
|
||||
</div>
|
||||
<div id="commit-list" class="commit-list">
|
||||
<div class="welcome-state">
|
||||
<div class="welcome-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.2" opacity="0.4">
|
||||
<circle cx="24" cy="24" r="20"/>
|
||||
<path d="M24 4a20 20 0 0 1 0 40"/>
|
||||
<circle cx="24" cy="24" r="6"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 id="welcome-title" data-i18n="welcomeTitle">欢迎使用 Helix</h2>
|
||||
<p id="welcome-desc" data-i18n="welcomeDesc">打开 Git 仓库以可视化提交历史、管理分支并查看变更 — 一切尽在流畅的界面中。</p>
|
||||
<button id="welcome-open-btn" class="btn-primary" data-i18n="openRepo">打开仓库</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="detail-section" class="panel">
|
||||
<div class="panel-header">
|
||||
<span data-i18n="changes">变更</span>
|
||||
<div class="panel-tabs">
|
||||
<button class="tab active" data-tab="unstaged" data-i18n="unstaged">未暂存</button>
|
||||
<button class="tab" data-tab="staged" data-i18n="staged">已暂存</button>
|
||||
<button class="tab" data-tab="diff" data-i18n="diff">差异</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="detail-content" class="detail-content">
|
||||
<div class="empty-state" data-i18n="selectDetail">选择提交或文件查看详情</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer id="status-bar">
|
||||
<div class="status-left" id="status-left" data-i18n="ready">就绪</div>
|
||||
<div class="status-right"><span id="status-branch"></span></div>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="modal-overlay" class="modal-overlay hidden">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span id="modal-title"></span>
|
||||
<button class="btn-icon modal-close" id="modal-close">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="1" y1="1" x2="13" y2="13"/><line x1="13" y1="1" x2="1" y2="13"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="modal-content"></div>
|
||||
<div class="modal-footer" id="modal-footer"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast hidden"></div>
|
||||
|
||||
<script src="i18n.js"></script>
|
||||
<script src="renderer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
438
src/renderer.js
Normal file
438
src/renderer.js
Normal file
@@ -0,0 +1,438 @@
|
||||
(function () {
|
||||
const { helix } = window;
|
||||
const { t, setLang, getLang } = window._;
|
||||
|
||||
let state = {
|
||||
repoPath: null,
|
||||
branches: [],
|
||||
status: { branch: '', files: [] },
|
||||
commits: [],
|
||||
selectedCommit: null,
|
||||
activeTab: 'unstaged'
|
||||
};
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const $$ = (sel) => document.querySelectorAll(sel);
|
||||
|
||||
function applyI18n() {
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
const key = el.dataset.i18n;
|
||||
el.textContent = t(key);
|
||||
});
|
||||
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||||
el.title = t(el.dataset.i18nTitle);
|
||||
});
|
||||
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||||
el.placeholder = t(el.dataset.i18nPlaceholder);
|
||||
});
|
||||
document.getElementById('welcome-title').textContent = t('welcomeTitle');
|
||||
document.getElementById('welcome-desc').textContent = t('welcomeDesc');
|
||||
document.getElementById('status-left').textContent = state.repoPath ? t('ready') : t('ready');
|
||||
if (!state.repoPath) {
|
||||
$('#appTitle') || placeholderCheck();
|
||||
}
|
||||
}
|
||||
|
||||
function placeholderCheck() {
|
||||
const statusLeft = document.getElementById('status-left');
|
||||
if (statusLeft && statusLeft.textContent === '\u5C31\u7EEA') statusLeft.textContent = t('ready');
|
||||
}
|
||||
|
||||
function showToast(message) {
|
||||
var toast = $('#toast');
|
||||
toast.textContent = message;
|
||||
toast.classList.add('visible');
|
||||
clearTimeout(toast._timeout);
|
||||
toast._timeout = setTimeout(function () { toast.classList.remove('visible'); }, 2200);
|
||||
}
|
||||
|
||||
function showModal(title, content, footer) {
|
||||
$('#modal-title').textContent = title;
|
||||
$('#modal-content').innerHTML = content;
|
||||
$('#modal-footer').innerHTML = footer || '';
|
||||
$('#modal-overlay').classList.remove('hidden');
|
||||
requestAnimationFrame(function () {
|
||||
$('#modal-overlay').classList.add('visible');
|
||||
});
|
||||
}
|
||||
|
||||
function hideModal() {
|
||||
$('#modal-overlay').classList.remove('visible');
|
||||
setTimeout(function () { $('#modal-overlay').classList.add('hidden'); }, 220);
|
||||
}
|
||||
|
||||
$('#modal-close').addEventListener('click', hideModal);
|
||||
$('#modal-overlay').addEventListener('click', function (e) {
|
||||
if (e.target === $('#modal-overlay')) hideModal();
|
||||
});
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') hideModal();
|
||||
});
|
||||
|
||||
async function openRepo() {
|
||||
try {
|
||||
const repoPath = await helix.openRepo();
|
||||
if (!repoPath) return;
|
||||
state.repoPath = repoPath;
|
||||
const name = repoPath.split(/[/\\]/).pop();
|
||||
$('#repo-name').textContent = name;
|
||||
$('#repo-info').classList.remove('hidden');
|
||||
$('#toolbar-title').textContent = name;
|
||||
$$('.sidebar-actions .btn-secondary').forEach(function (b) { b.classList.remove('hidden'); });
|
||||
$('#new-branch-btn').classList.remove('hidden');
|
||||
await refreshAll();
|
||||
} catch (err) {
|
||||
showToast(t('failedStatus'));
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
if (!state.repoPath) return;
|
||||
$('#status-left').textContent = t('refreshing');
|
||||
try {
|
||||
await Promise.all([loadStatus(), loadBranches(), loadCommits()]);
|
||||
} catch (e) { /* ignore individual failures */ }
|
||||
$('#status-left').textContent = t('ready');
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
if (!state.repoPath) return;
|
||||
try {
|
||||
state.status = await helix.getStatus(state.repoPath);
|
||||
renderStatus();
|
||||
renderFileList();
|
||||
$('#repo-branch').textContent = state.status.branch || '\u2014';
|
||||
$('#status-branch').textContent = state.status.branch ? (t('on') + ' ' + state.status.branch) : '\u2014';
|
||||
} catch (e) { showToast(t('failedStatus')); }
|
||||
}
|
||||
|
||||
async function loadBranches() {
|
||||
if (!state.repoPath) return;
|
||||
try {
|
||||
state.branches = await helix.getBranches(state.repoPath);
|
||||
renderBranches();
|
||||
} catch (e) { showToast(t('failedBranches')); }
|
||||
}
|
||||
|
||||
async function loadCommits() {
|
||||
if (!state.repoPath) return;
|
||||
try {
|
||||
state.commits = await helix.getLog(state.repoPath);
|
||||
renderCommits();
|
||||
$('#commit-count').textContent = state.commits.length;
|
||||
} catch (e) { showToast(t('failedCommits')); }
|
||||
}
|
||||
|
||||
function renderBranches() {
|
||||
var container = $('#branch-list');
|
||||
if (state.branches.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state">' + t('noBranches') + '</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = state.branches.map(function (b) {
|
||||
var classes = ['branch-item'];
|
||||
if (b.isCurrent) classes.push('current');
|
||||
return '<div class="' + classes.join(' ') + '" data-branch="' + escapeAttr(b.name) + '"><span>' + escapeHtml(b.name) + '</span><span class="branch-hash">' + escapeHtml(b.hash || '') + '</span></div>';
|
||||
}).join('');
|
||||
|
||||
container.querySelectorAll('.branch-item').forEach(function (el) {
|
||||
el.addEventListener('click', async function () {
|
||||
var branch = el.dataset.branch;
|
||||
try {
|
||||
await helix.checkout(state.repoPath, branch);
|
||||
showToast(t('switchedTo') + ' ' + branch);
|
||||
await refreshAll();
|
||||
} catch (e) { showToast(t('failedSwitch') + ': ' + e.message); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderStatus() {
|
||||
var container = $('#status-summary');
|
||||
var files = state.status.files;
|
||||
if (files.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state">' + t('cleanTree') + '</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = files.map(function (f) {
|
||||
var mc = getMarkerClass(f.status);
|
||||
return '<div class="status-file" data-file="' + escapeAttr(f.filename) + '" data-status="' + f.status + '"><span class="status-marker ' + mc + '">' + f.status + '</span><span>' + escapeHtml(f.filename) + '</span></div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function getMarkerClass(status) {
|
||||
if (status && status.indexOf('A') >= 0) return 'added';
|
||||
if (status && status.indexOf('D') >= 0) return 'deleted';
|
||||
if (status && status.indexOf('R') >= 0) return 'renamed';
|
||||
if (status && status.indexOf('M') >= 0) return 'modified';
|
||||
if (status === '??') return 'untracked';
|
||||
return 'modified';
|
||||
}
|
||||
|
||||
function renderCommits() {
|
||||
var container = $('#commit-list');
|
||||
if (state.commits.length === 0) return;
|
||||
container.innerHTML = state.commits.map(function (c) {
|
||||
var initials = (c.author || '?').split(' ').map(function (w) { return w[0]; }).join('').slice(0, 2).toUpperCase();
|
||||
var refs = parseRefs(c.refs);
|
||||
return '<div class="commit-item" data-hash="' + c.hash + '">' +
|
||||
'<div class="commit-avatar">' + initials + '</div>' +
|
||||
'<div class="commit-body">' +
|
||||
'<div class="commit-subject">' + escapeHtml(c.subject) + '</div>' +
|
||||
'<div class="commit-meta">' +
|
||||
'<span class="commit-author">' + escapeHtml(c.author) + '</span>' +
|
||||
'<span class="commit-hash">' + escapeHtml(c.shortHash || '') + '</span>' +
|
||||
'<span>' + escapeHtml(c.date || '') + '</span>' +
|
||||
'</div>' +
|
||||
(refs.length ? '<div class="commit-refs">' + refs.map(function (r) { return '<span class="ref-tag ' + r.type + '">' + escapeHtml(r.name) + '</span>'; }).join('') + '</div>' : '') +
|
||||
'</div></div>';
|
||||
}).join('');
|
||||
|
||||
container.querySelectorAll('.commit-item').forEach(function (el) {
|
||||
el.addEventListener('click', async function () {
|
||||
container.querySelectorAll('.commit-item').forEach(function (e) { e.classList.remove('selected'); });
|
||||
el.classList.add('selected');
|
||||
state.selectedCommit = el.dataset.hash;
|
||||
state.activeTab = 'diff';
|
||||
$$('.tab').forEach(function (t) { t.classList.remove('active'); });
|
||||
var diffTab = document.querySelector('.tab[data-tab="diff"]');
|
||||
if (diffTab) diffTab.classList.add('active');
|
||||
await loadDiff(state.selectedCommit);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function parseRefs(refsStr) {
|
||||
if (!refsStr) return [];
|
||||
return refsStr.split(',').map(function (r) {
|
||||
var trimmed = r.trim();
|
||||
if (trimmed.indexOf('HEAD ->') === 0) return { name: trimmed, type: 'branch' };
|
||||
if (trimmed.indexOf('tag:') === 0) return { name: trimmed, type: 'branch' };
|
||||
if (trimmed.indexOf('origin/') >= 0) return { name: trimmed, type: 'remote' };
|
||||
return { name: trimmed, type: 'branch' };
|
||||
});
|
||||
}
|
||||
|
||||
async function loadDiff(hash) {
|
||||
if (!state.repoPath) return;
|
||||
try {
|
||||
var diff = await helix.getDiff(state.repoPath, hash || null);
|
||||
var content = $('#detail-content');
|
||||
content.innerHTML = diff ? formatDiff(diff) : '<div class="empty-state">' + t('noChanges') + '</div>';
|
||||
} catch (e) {
|
||||
$('#detail-content').innerHTML = '<div class="empty-state">' + t('failedDiff') + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function formatDiff(text) {
|
||||
return text.split('\n').map(function (line) {
|
||||
if (line.match(/^\s+\S.*\|/)) return '<div class="diff-file">' + escapeHtml(line) + '</div>';
|
||||
return escapeHtml(line);
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
async function renderFileList() {
|
||||
var content = $('#detail-content');
|
||||
var files = state.status.files;
|
||||
var filtered;
|
||||
if (state.activeTab === 'staged') {
|
||||
filtered = files.filter(function (f) { return f.status !== '??' && f.status !== ' M' && f.status !== ' D'; });
|
||||
} else if (state.activeTab === 'unstaged') {
|
||||
filtered = files.filter(function (f) { return f.status === '??' || f.status === ' M' || f.status === ' D'; });
|
||||
} else {
|
||||
filtered = files;
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
content.innerHTML = '<div class="empty-state">' + t('noChanges') + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
content.innerHTML = filtered.map(function (f) {
|
||||
return '<div class="file-list-item" data-file="' + escapeAttr(f.filename) + '" data-status="' + f.status + '">' +
|
||||
'<span><span class="status-marker ' + getMarkerClass(f.status) + '">' + f.status + '</span> ' + escapeHtml(f.filename) + '</span>' +
|
||||
'<span class="file-actions">' +
|
||||
(state.activeTab === 'unstaged' ? '<button class="btn-small stage-btn">' + t('stageBtn') + '</button>' : '') +
|
||||
(state.activeTab === 'staged' ? '<button class="btn-small unstage-btn">' + t('unstageBtn') + '</button>' : '') +
|
||||
(state.activeTab === 'unstaged' ? '<button class="btn-small discard-btn">' + t('discardFileBtn') + '</button>' : '') +
|
||||
'</span></div>';
|
||||
}).join('');
|
||||
|
||||
content.querySelectorAll('.stage-btn').forEach(function (btn) {
|
||||
btn.addEventListener('click', async function (e) {
|
||||
e.stopPropagation();
|
||||
var file = btn.closest('.file-list-item').dataset.file;
|
||||
try { await helix.stage(state.repoPath, [file]); showToast(t('stagedF') + ' ' + file); await refreshAll(); }
|
||||
catch (e) { showToast(t('failedStage')); }
|
||||
});
|
||||
});
|
||||
|
||||
content.querySelectorAll('.unstage-btn').forEach(function (btn) {
|
||||
btn.addEventListener('click', async function (e) {
|
||||
e.stopPropagation();
|
||||
var file = btn.closest('.file-list-item').dataset.file;
|
||||
try { await helix.unstage(state.repoPath, [file]); showToast(t('unstagedF') + ' ' + file); await refreshAll(); }
|
||||
catch (e) { showToast(t('failedUnstage')); }
|
||||
});
|
||||
});
|
||||
|
||||
content.querySelectorAll('.discard-btn').forEach(function (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
var file = btn.closest('.file-list-item').dataset.file;
|
||||
showModal(t('discardTitle'),
|
||||
'<p style="font-size:0.875rem;color:var(--text-secondary);margin-bottom:8px">' + t('discardDesc') + ' <strong>' + escapeHtml(file) + '</strong>' + t('discardDesc2') + '</p>',
|
||||
'<button class="btn-secondary cancel-discard-btn">' + t('cancel') + '</button><button class="btn-primary confirm-discard-btn" style="background:var(--red)">' + t('discardBtn') + '</button>');
|
||||
document.querySelector('.cancel-discard-btn').addEventListener('click', hideModal);
|
||||
document.querySelector('.confirm-discard-btn').addEventListener('click', async function () {
|
||||
hideModal();
|
||||
try { await helix.discard(state.repoPath, file); showToast(t('discardedF') + ' ' + file); await refreshAll(); }
|
||||
catch (e) { showToast(t('failedDiscard')); }
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
$('#detail-section').addEventListener('click', function (e) {
|
||||
if (e.target.classList.contains('tab')) {
|
||||
$$('.tab').forEach(function (t) { t.classList.remove('active'); });
|
||||
e.target.classList.add('active');
|
||||
state.activeTab = e.target.dataset.tab;
|
||||
state.selectedCommit = null;
|
||||
$$('.commit-item').forEach(function (el) { el.classList.remove('selected'); });
|
||||
if (state.activeTab === 'diff' && state.selectedCommit) {
|
||||
loadDiff(state.selectedCommit);
|
||||
} else {
|
||||
renderFileList();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$('#open-repo-btn').addEventListener('click', openRepo);
|
||||
$('#welcome-open-btn').addEventListener('click', openRepo);
|
||||
$('#refresh-btn').addEventListener('click', refreshAll);
|
||||
|
||||
$('#new-branch-btn').addEventListener('click', function () {
|
||||
showModal(t('createBranch'),
|
||||
'<p style="font-size:0.8125rem;color:var(--text-secondary);margin-bottom:12px">' + t('createBranchDesc') + '</p>' +
|
||||
'<input class="modal-input" id="branch-name-input" placeholder="' + t('branchName') + '" autofocus>',
|
||||
'<button class="btn-secondary cancel-branch-btn">' + t('cancel') + '</button><button class="btn-primary confirm-branch-btn">' + t('create') + '</button>');
|
||||
document.querySelector('.cancel-branch-btn').addEventListener('click', hideModal);
|
||||
document.querySelector('.confirm-branch-btn').addEventListener('click', async function () {
|
||||
var name = document.getElementById('branch-name-input').value.trim();
|
||||
if (!name) return;
|
||||
hideModal();
|
||||
try { await helix.createBranch(state.repoPath, name); showToast(t('createdBranch') + ' ' + name); await refreshAll(); }
|
||||
catch (e) { showToast(t('failedCreateBranch') + ': ' + e.message); }
|
||||
});
|
||||
});
|
||||
|
||||
$('#fetch-btn').addEventListener('click', async function () {
|
||||
try { await helix.fetch(state.repoPath); showToast(t('fetched')); await refreshAll(); }
|
||||
catch (e) { showToast(t('failedFetch')); }
|
||||
});
|
||||
$('#pull-btn').addEventListener('click', async function () {
|
||||
try { await helix.pull(state.repoPath); showToast(t('pulled')); await refreshAll(); }
|
||||
catch (e) { showToast(t('failedPull') + ': ' + e.message); }
|
||||
});
|
||||
$('#push-btn').addEventListener('click', async function () {
|
||||
try { await helix.push(state.repoPath); showToast(t('pushed')); await refreshAll(); }
|
||||
catch (e) { showToast(t('failedPush') + ': ' + e.message); }
|
||||
});
|
||||
|
||||
function showCommitDialog() {
|
||||
showModal(t('commitChanges'),
|
||||
'<p style="font-size:0.8125rem;color:var(--text-secondary);margin-bottom:12px">' + t('commitDesc') + '</p>' +
|
||||
'<input class="modal-input" id="commit-message-input" placeholder="' + t('commitMessage') + '" autofocus>',
|
||||
'<button class="btn-secondary cancel-commit-btn">' + t('cancel') + '</button><button class="btn-primary confirm-commit-btn">' + t('commit') + '</button>');
|
||||
document.querySelector('.cancel-commit-btn').addEventListener('click', hideModal);
|
||||
document.querySelector('.confirm-commit-btn').addEventListener('click', async function () {
|
||||
var message = document.getElementById('commit-message-input').value.trim();
|
||||
if (!message) return;
|
||||
hideModal();
|
||||
try { await helix.commit(state.repoPath, message); showToast(t('committed')); await refreshAll(); }
|
||||
catch (e) { showToast(t('failedCommit') + ': ' + e.message); }
|
||||
});
|
||||
}
|
||||
|
||||
var commitBtn = document.createElement('button');
|
||||
commitBtn.className = 'btn-primary';
|
||||
commitBtn.id = 'commit-btn';
|
||||
commitBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M7 1v9M4 7l3 3 3-3"/></svg> ' + t('commit');
|
||||
commitBtn.addEventListener('click', showCommitDialog);
|
||||
$('#toolbar-actions').appendChild(commitBtn);
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
var div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function escapeAttr(str) {
|
||||
if (!str) return '';
|
||||
return String(str).replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/* ── Settings ── */
|
||||
$('#settings-btn').addEventListener('click', function () {
|
||||
var currentLang = getLang();
|
||||
showModal(t('settings'),
|
||||
'<div class="settings-section">' +
|
||||
'<label>' + t('languageLabel') + '</label>' +
|
||||
'<div class="settings-row">' +
|
||||
'<button class="settings-option' + (currentLang === 'zh-CN' ? ' active' : '') + '" data-lang="zh-CN">\u4e2d\u6587</button>' +
|
||||
'<button class="settings-option' + (currentLang === 'en' ? ' active' : '') + '" data-lang="en">English</button>' +
|
||||
'</div>' +
|
||||
'</div>',
|
||||
'<button class="btn-secondary settings-close-btn">' + t('cancel') + '</button><button class="btn-primary settings-save-btn">\u2714</button>');
|
||||
|
||||
document.querySelector('.settings-close-btn').addEventListener('click', hideModal);
|
||||
|
||||
document.querySelectorAll('.settings-option').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
document.querySelectorAll('.settings-option').forEach(function (b) { b.classList.remove('active'); });
|
||||
btn.classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelector('.settings-save-btn').addEventListener('click', async function () {
|
||||
var selected = document.querySelector('.settings-option.active');
|
||||
var newLang = selected ? selected.dataset.lang : 'zh-CN';
|
||||
if (newLang !== getLang()) {
|
||||
setLang(newLang);
|
||||
await helix.saveSettings({ language: newLang });
|
||||
applyI18n();
|
||||
commitBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M7 1v9M4 7l3 3 3-3"/></svg> ' + t('commit');
|
||||
if (state.repoPath) await refreshAll();
|
||||
}
|
||||
hideModal();
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function handler(e) {
|
||||
if (e.key === 'Escape') { hideModal(); document.removeEventListener('keydown', handler); }
|
||||
});
|
||||
});
|
||||
|
||||
/* ── Init ── */
|
||||
async function init() {
|
||||
try {
|
||||
var settings = await helix.loadSettings();
|
||||
if (settings && settings.language) {
|
||||
setLang(settings.language);
|
||||
document.documentElement.lang = settings.language;
|
||||
}
|
||||
} catch (e) { /* default zh-CN */ }
|
||||
applyI18n();
|
||||
commitBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M7 1v9M4 7l3 3 3-3"/></svg> ' + t('commit');
|
||||
document.getElementById('status-left').textContent = t('ready');
|
||||
|
||||
/* ── Title bar buttons ── */
|
||||
document.getElementById('btn-minimize').addEventListener('click', function () { helix.minimize(); });
|
||||
document.getElementById('btn-maximize').addEventListener('click', function () { helix.maximize(); });
|
||||
document.getElementById('btn-close').addEventListener('click', function () { helix.close(); });
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
})();
|
||||
632
src/styles.css
Normal file
632
src/styles.css
Normal file
@@ -0,0 +1,632 @@
|
||||
@font-face {
|
||||
font-family: 'MiSans';
|
||||
src: local('MiSans'), local('MiSans Regular');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'MiSans';
|
||||
src: local('MiSans Medium'), local('MiSans-Medium');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'MiSans';
|
||||
src: local('MiSans Semibold'), local('MiSans-Semibold');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-display: "MiSans", "SF Pro Display", "Microsoft YaHei UI", "PingFang SC", system-ui, -apple-system, sans-serif;
|
||||
--font-body: "MiSans", "SF Pro Text", "Microsoft YaHei UI", "PingFang SC", system-ui, -apple-system, sans-serif;
|
||||
--font-mono: "Cascadia Code", "SF Mono", "Fira Code", "Consolas", monospace;
|
||||
|
||||
--blur-sidebar: 12px;
|
||||
--blur-toolbar: 10px;
|
||||
--blur-panel: 8px;
|
||||
--blur-modal: 30px;
|
||||
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
|
||||
--spacing-xs: 4px;
|
||||
--spacing-sm: 8px;
|
||||
--spacing-md: 12px;
|
||||
--spacing-lg: 16px;
|
||||
--spacing-xl: 24px;
|
||||
|
||||
font: 100%/1.6 var(--font-body);
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--bg-root: #f5f5f7;
|
||||
--bg-sidebar: #f0f0f3;
|
||||
--bg-toolbar: #fafafc;
|
||||
--bg-panel: #ffffff;
|
||||
--bg-panel-alt: #fafafc;
|
||||
--bg-hover: rgba(0, 0, 0, 0.05);
|
||||
--bg-active: rgba(0, 0, 0, 0.08);
|
||||
--bg-selected: rgba(0, 122, 255, 0.1);
|
||||
--bg-modal: rgba(255, 255, 255, 0.97);
|
||||
--bg-tag: rgba(0, 0, 0, 0.06);
|
||||
--bg-red-soft: rgba(255, 59, 48, 0.08);
|
||||
--bg-green-soft: rgba(52, 199, 89, 0.08);
|
||||
--bg-blue-soft: rgba(0, 122, 255, 0.08);
|
||||
--bg-titlebar: #f0f0f3;
|
||||
|
||||
--text-primary: #1d1d1f;
|
||||
--text-secondary: #6e6e73;
|
||||
--text-tertiary: #aeaeb2;
|
||||
--text-link: #0066cc;
|
||||
|
||||
--border-subtle: rgba(0, 0, 0, 0.08);
|
||||
--border-soft: rgba(0, 0, 0, 0.12);
|
||||
--border-prominent: rgba(0, 0, 0, 0.2);
|
||||
|
||||
--accent: #007aff;
|
||||
--accent-hover: #0070e9;
|
||||
--accent-pressed: #0062cc;
|
||||
|
||||
--red: #ff3b30;
|
||||
--green: #34c759;
|
||||
--orange: #ff9500;
|
||||
--blue: #007aff;
|
||||
--yellow: #ffcc00;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.06);
|
||||
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.08);
|
||||
--shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.15);
|
||||
--scrim: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg-root: #1c1c1e;
|
||||
--bg-sidebar: #252528;
|
||||
--bg-toolbar: #2c2c2e;
|
||||
--bg-panel: #2c2c2e;
|
||||
--bg-panel-alt: #242426;
|
||||
--bg-hover: rgba(255, 255, 255, 0.06);
|
||||
--bg-active: rgba(255, 255, 255, 0.1);
|
||||
--bg-selected: rgba(10, 132, 255, 0.2);
|
||||
--bg-modal: rgba(44, 44, 46, 0.98);
|
||||
--bg-tag: rgba(255, 255, 255, 0.08);
|
||||
--bg-red-soft: rgba(255, 69, 58, 0.12);
|
||||
--bg-green-soft: rgba(48, 209, 88, 0.12);
|
||||
--bg-blue-soft: rgba(10, 132, 255, 0.12);
|
||||
--bg-titlebar: #252528;
|
||||
|
||||
--text-primary: #f5f5f7;
|
||||
--text-secondary: #98989d;
|
||||
--text-tertiary: #636366;
|
||||
--text-link: #409cff;
|
||||
|
||||
--border-subtle: rgba(255, 255, 255, 0.06);
|
||||
--border-soft: rgba(255, 255, 255, 0.1);
|
||||
--border-prominent: rgba(255, 255, 255, 0.18);
|
||||
|
||||
--accent: #0a84ff;
|
||||
--accent-hover: #409cff;
|
||||
--accent-pressed: #0070d6;
|
||||
|
||||
--red: #ff453a;
|
||||
--green: #30d158;
|
||||
--orange: #ff9f0a;
|
||||
--blue: #0a84ff;
|
||||
--yellow: #ffd60a;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.5);
|
||||
--shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.6);
|
||||
--scrim: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
margin: 0; padding: 0; box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%; overflow: hidden;
|
||||
background: var(--bg-root);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#title-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
background: var(--bg-titlebar);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
-webkit-app-region: drag;
|
||||
z-index: 100;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.title-bar-drag {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.title-bar-title {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 590;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.title-bar-controls {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.title-btn {
|
||||
width: 46px;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 80ms;
|
||||
}
|
||||
|
||||
.title-btn:hover { background: var(--bg-hover); }
|
||||
.title-btn-close:hover { background: var(--red); color: white; }
|
||||
|
||||
#app {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
height: calc(100vh - 32px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#sidebar {
|
||||
background: var(--bg-sidebar);
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
padding: 0 var(--spacing-md);
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md) 0;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-settings { margin-left: auto; }
|
||||
|
||||
.repo-info {
|
||||
padding: var(--spacing-md) 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.repo-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 590;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.repo-branch {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.sidebar-section { margin-bottom: var(--spacing-lg); }
|
||||
|
||||
.section-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
padding: 0 var(--spacing-xs);
|
||||
}
|
||||
|
||||
.branch-list { display: flex; flex-direction: column; gap: 1px; }
|
||||
|
||||
.branch-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px var(--spacing-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
transition: background 80ms;
|
||||
}
|
||||
|
||||
.branch-item:hover { background: var(--bg-hover); }
|
||||
.branch-item:active { background: var(--bg-active); transform: scale(0.97); }
|
||||
.branch-item.current { background: var(--bg-selected); font-weight: 500; color: var(--accent); }
|
||||
.branch-hash { font-family: var(--font-mono); font-size: 0.6875rem; color: var(--text-tertiary); }
|
||||
|
||||
.status-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
padding: 4px var(--spacing-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.status-file:hover { background: var(--bg-hover); }
|
||||
.status-file:active { background: var(--bg-active); transform: scale(0.97); }
|
||||
|
||||
.status-marker {
|
||||
font-weight: 600; font-size: 0.6875rem;
|
||||
width: 16px; text-align: center; flex-shrink: 0;
|
||||
}
|
||||
.status-marker.modified { color: var(--orange); }
|
||||
.status-marker.added { color: var(--green); }
|
||||
.status-marker.deleted { color: var(--red); }
|
||||
.status-marker.renamed { color: var(--blue); }
|
||||
.status-marker.untracked { color: var(--text-tertiary); }
|
||||
|
||||
.sidebar-actions {
|
||||
display: flex; gap: var(--spacing-xs);
|
||||
padding: var(--spacing-md) 0;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 30px; height: 30px;
|
||||
border: none; border-radius: var(--radius-sm);
|
||||
background: transparent; color: var(--text-primary);
|
||||
cursor: pointer; transition: background 80ms;
|
||||
}
|
||||
.btn-icon:hover { background: var(--bg-hover); }
|
||||
.btn-icon:active { transform: scale(0.92); background: var(--bg-active); }
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
gap: var(--spacing-xs); padding: 7px var(--spacing-lg);
|
||||
border: none; border-radius: var(--radius-md);
|
||||
background: var(--accent); color: white;
|
||||
font-size: 0.8125rem; font-weight: 500;
|
||||
cursor: pointer; transition: background 120ms;
|
||||
}
|
||||
.btn-primary:hover { background: var(--accent-hover); }
|
||||
.btn-primary:active { transform: scale(0.96); background: var(--accent-pressed); }
|
||||
|
||||
.btn-secondary {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
gap: var(--spacing-xs); padding: 5px var(--spacing-md);
|
||||
border: 1px solid var(--border-soft); border-radius: var(--radius-sm);
|
||||
background: rgba(128,128,128,0.08); color: var(--text-primary);
|
||||
font-size: 0.75rem; font-weight: 500;
|
||||
cursor: pointer; transition: background 80ms;
|
||||
}
|
||||
.btn-secondary:hover { background: var(--bg-active); }
|
||||
.btn-secondary:active { transform: scale(0.96); }
|
||||
|
||||
.btn-small {
|
||||
display: inline-flex; align-items: center;
|
||||
padding: 2px 8px; border: none; border-radius: var(--radius-sm);
|
||||
background: transparent; color: var(--text-secondary);
|
||||
font-size: 0.6875rem; cursor: pointer;
|
||||
}
|
||||
.btn-small:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||
|
||||
#toolbar {
|
||||
background: var(--bg-toolbar);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 var(--spacing-lg); height: 44px;
|
||||
}
|
||||
.toolbar-left { display: flex; align-items: center; gap: var(--spacing-sm); }
|
||||
.toolbar-title {
|
||||
font-size: 0.9375rem; font-weight: 590;
|
||||
color: var(--text-primary); letter-spacing: -0.01em;
|
||||
}
|
||||
.toolbar-right { display: flex; align-items: center; gap: var(--spacing-sm); }
|
||||
|
||||
#content-area {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
height: calc(100vh - 32px - 44px - 24px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: flex; flex-direction: column;
|
||||
overflow: hidden; border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.panel:last-child { border-bottom: none; }
|
||||
|
||||
.panel-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
font-size: 0.75rem; font-weight: 600;
|
||||
letter-spacing: 0.02em; color: var(--text-secondary);
|
||||
background: var(--bg-panel-alt);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0; height: 36px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.625rem; font-weight: 600;
|
||||
padding: 1px 6px; border-radius: 10px;
|
||||
background: var(--bg-tag); color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.panel-tabs { display: flex; gap: 1px; }
|
||||
|
||||
.tab {
|
||||
padding: 4px var(--spacing-md);
|
||||
border: none; border-radius: var(--radius-sm);
|
||||
background: transparent; color: var(--text-tertiary);
|
||||
font-size: 0.6875rem; font-weight: 500;
|
||||
cursor: pointer; transition: all 80ms;
|
||||
}
|
||||
.tab:hover { color: var(--text-primary); background: var(--bg-hover); }
|
||||
.tab:active { transform: scale(0.96); }
|
||||
.tab.active { color: var(--accent); background: var(--bg-selected); }
|
||||
|
||||
.commit-list { flex: 1; overflow-y: auto; }
|
||||
|
||||
.commit-item {
|
||||
display: flex; align-items: flex-start;
|
||||
gap: var(--spacing-md); padding: var(--spacing-md) var(--spacing-lg);
|
||||
cursor: pointer; border-bottom: 1px solid var(--border-subtle);
|
||||
transition: background 80ms;
|
||||
}
|
||||
.commit-item:hover { background: var(--bg-hover); }
|
||||
.commit-item:active { background: var(--bg-active); transform: scale(0.995); }
|
||||
.commit-item.selected { background: var(--bg-selected); }
|
||||
|
||||
.commit-avatar {
|
||||
width: 28px; height: 28px; border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--accent), #5856d6);
|
||||
color: white; display: flex; align-items: center; justify-content: center;
|
||||
font-size: 0.6875rem; font-weight: 600; flex-shrink: 0; margin-top: 2px;
|
||||
}
|
||||
|
||||
.commit-body { flex: 1; min-width: 0; }
|
||||
.commit-subject {
|
||||
font-size: 0.875rem; font-weight: 500;
|
||||
color: var(--text-primary); line-height: 1.3; letter-spacing: -0.01em;
|
||||
}
|
||||
.commit-meta {
|
||||
display: flex; align-items: center; gap: var(--spacing-md);
|
||||
margin-top: 4px; font-size: 0.75rem; color: var(--text-tertiary);
|
||||
}
|
||||
.commit-author { color: var(--text-secondary); font-weight: 500; }
|
||||
.commit-hash { font-family: var(--font-mono); font-size: 0.6875rem; }
|
||||
|
||||
.commit-refs { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 4px; }
|
||||
.ref-tag {
|
||||
font-size: 0.625rem; font-weight: 600; padding: 1px 6px;
|
||||
border-radius: 4px; background: var(--bg-tag);
|
||||
color: var(--text-secondary); font-family: var(--font-mono);
|
||||
}
|
||||
.ref-tag.branch { background: var(--bg-green-soft); color: var(--green); }
|
||||
.ref-tag.remote { background: var(--bg-blue-soft); color: var(--blue); }
|
||||
|
||||
.detail-content {
|
||||
flex: 1; overflow-y: auto; padding: var(--spacing-lg);
|
||||
font-family: var(--font-mono); font-size: 0.8125rem;
|
||||
line-height: 1.6; white-space: pre-wrap; word-break: break-all;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.diff-file {
|
||||
color: var(--text-primary); font-weight: 600;
|
||||
font-family: var(--font-body); font-size: 0.875rem;
|
||||
margin-top: var(--spacing-md); letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.file-list-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 6px var(--spacing-sm); border-radius: var(--radius-sm);
|
||||
cursor: pointer; font-size: 0.8125rem; font-family: var(--font-mono);
|
||||
}
|
||||
.file-list-item:hover { background: var(--bg-hover); }
|
||||
.file-list-item:active { transform: scale(0.99); background: var(--bg-active); }
|
||||
.file-actions { display: flex; gap: var(--spacing-xs); }
|
||||
|
||||
#status-bar {
|
||||
background: var(--bg-toolbar);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 var(--spacing-lg); height: 24px;
|
||||
font-size: 0.6875rem; color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; z-index: 100;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--scrim); opacity: 0; pointer-events: none;
|
||||
transition: opacity 200ms;
|
||||
}
|
||||
.modal-overlay.visible { opacity: 1; pointer-events: auto; }
|
||||
|
||||
.modal {
|
||||
background: var(--bg-modal);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-modal);
|
||||
border: 1px solid var(--border-subtle);
|
||||
width: 440px; max-height: 80vh;
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
transform: scale(0.94) translateY(12px);
|
||||
transition: transform 250ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.modal-overlay.visible .modal { transform: scale(1) translateY(0); }
|
||||
|
||||
.modal-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: var(--spacing-lg); border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.modal-header span {
|
||||
font-size: 0.9375rem; font-weight: 590; letter-spacing: -0.01em;
|
||||
}
|
||||
#modal-content { padding: var(--spacing-lg); overflow-y: auto; flex: 1; }
|
||||
.modal-footer {
|
||||
display: flex; justify-content: flex-end; gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.modal-input {
|
||||
width: 100%; padding: 8px var(--spacing-md);
|
||||
border: 1px solid var(--border-soft); border-radius: var(--radius-sm);
|
||||
background: var(--bg-panel); color: var(--text-primary);
|
||||
font-family: var(--font-body); font-size: 0.875rem;
|
||||
outline: none; transition: border-color 120ms;
|
||||
}
|
||||
.modal-input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.15);
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed; bottom: 32px; left: 50%;
|
||||
transform: translateX(-50%) translateY(16px);
|
||||
background: var(--bg-modal);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 10px var(--spacing-xl);
|
||||
font-size: 0.8125rem; font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 200; opacity: 0; pointer-events: none;
|
||||
transition: all 250ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.toast.visible { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
|
||||
.welcome-state {
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
height: 100%; padding: var(--spacing-xl);
|
||||
text-align: center; gap: var(--spacing-md);
|
||||
}
|
||||
.welcome-icon { margin-bottom: var(--spacing-sm); }
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-8px); }
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.welcome-icon { animation: float 3s ease-in-out infinite; }
|
||||
}
|
||||
|
||||
.welcome-state h2 {
|
||||
font-size: 1.5rem; font-weight: 590;
|
||||
letter-spacing: -0.02em; color: var(--text-primary);
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
.welcome-state p {
|
||||
font-size: 0.875rem; color: var(--text-secondary);
|
||||
max-width: 360px; line-height: 1.5;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
font-size: 0.75rem; color: var(--text-tertiary);
|
||||
text-align: center; padding: var(--spacing-xl) var(--spacing-md);
|
||||
}
|
||||
|
||||
.hidden { display: none !important; }
|
||||
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border-soft); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--border-prominent); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
.welcome-icon { animation: none; }
|
||||
.btn-icon:active, .btn-primary:active, .btn-secondary:active,
|
||||
.btn-small:active, .branch-item:active, .commit-item:active,
|
||||
.status-file:active, .tab:active, .file-list-item:active {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
:root {
|
||||
--border-subtle: rgba(128,128,128,0.4);
|
||||
--border-soft: rgba(128,128,128,0.6);
|
||||
--text-secondary: #000;
|
||||
--text-tertiary: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
.settings-section label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.settings-row {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
.settings-option {
|
||||
flex: 1;
|
||||
padding: 8px var(--spacing-md);
|
||||
border: 2px solid var(--border-soft);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-panel);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.8125rem;
|
||||
font-family: var(--font-body);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: all 120ms;
|
||||
}
|
||||
.settings-option:hover {
|
||||
border-color: var(--border-prominent);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.settings-option.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-selected);
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
Reference in New Issue
Block a user