Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
627adc907e | ||
|
|
b2b2f49db6 | ||
|
|
1dfe081713 | ||
|
|
a107ec1f2c | ||
|
|
4681ff914f | ||
|
|
e1a5793a62 | ||
|
|
fcf7952ee3 | ||
|
|
08bb38b188 | ||
|
|
acb388d22d | ||
|
|
1bc913c54e | ||
|
|
8692edd2dd |
@@ -32,6 +32,7 @@ workspace/
|
||||
|
||||
# Local JDK
|
||||
zulu8.94.0.17-ca-jdk8.0.492-macosx_x64/
|
||||
zulu8.94.0.17-ca-jdk8.0.492-macosx_aarch64/
|
||||
|
||||
# E2E 截图
|
||||
**/.e2e-shots/
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
# macTFS
|
||||
|
||||
> 🤖 **本项目由 Claude(Anthropic AI)生成**:从产品需求、前后端代码,到这份 README 和界面截图,均为纯 AI 生成的实验性作品。
|
||||
>
|
||||
> 🤖 _Built entirely by Claude (Anthropic's AI). Requirements, code, this README, and screenshots are all AI-generated._
|
||||
|
||||
> macOS 上的 TFS(Azure DevOps Server)版本控制客户端 —— 脱离 IDE 宿主,独立运行的桌面应用 + 本地 HTTP API。
|
||||
|
||||
macTFS 让你在 **macOS(含 Apple Silicon)** 上直接对 **TFS / TFVC** 仓库做日常版本控制:获取最新、签出、签入、查看历史、目录与文件对比……不再依赖 Windows + Visual Studio,也不用为了一个 TFS 插件背着一整个 IntelliJ。
|
||||
|
||||
它由一个 **Electron 桌面前端** 和一个 **常驻本地 Java 服务** 组成。Java 服务复用微软官方的 TFS Java SDK 与 TFS 通信,并把能力封装成 `127.0.0.1` 上的 REST API —— 所以除了图形界面,你也可以用 `curl` / 脚本 / CI 直接调用。
|
||||
|
||||
---
|
||||
|
||||
## 截图
|
||||
|
||||
> 以下为演示数据,非真实服务器内容。
|
||||
|
||||
### 连接 TFS
|
||||
|
||||
| 填写连接信息 | 选择 Collection |
|
||||
| :---: | :---: |
|
||||
|  |  |
|
||||
|
||||
### 工作台
|
||||
|
||||
三栏布局:左侧服务端目录树、中间文件列表、右侧挂起更改与签入、底部操作日志。
|
||||
|
||||

|
||||
|
||||
### 对比与 Diff
|
||||
|
||||
| 目录对比 | 文件 Diff(Monaco) |
|
||||
| :---: | :---: |
|
||||
|  |  |
|
||||
|
||||
---
|
||||
|
||||
## 下载安装
|
||||
|
||||
普通用户无需自行构建,直接到 [GitHub Releases](https://github.com/Farewell0375/MacTFS/releases/latest) 下载对应芯片的安装包:
|
||||
|
||||
| 安装包 | 适用机型 |
|
||||
| --- | --- |
|
||||
| `MacTFS-<版本>-arm64.dmg` | Apple Silicon(M 系列芯片) |
|
||||
| `MacTFS-<版本>-x64.dmg` | Intel 芯片 |
|
||||
|
||||
打开 DMG,把 `MacTFS.app` 拖进「应用程序」即可。
|
||||
|
||||
### 首次打开:应用未签名怎么办
|
||||
|
||||
本应用**未做 Apple 代码签名 / 公证**,首次打开 macOS 的 Gatekeeper 会拦截,提示「无法验证开发者」或「"MacTFS" 已损坏,无法打开」。这是未签名应用的正常现象,二选一解除即可:
|
||||
|
||||
**方法一 · 图形界面**
|
||||
|
||||
1. 双击 `MacTFS.app`,弹窗里先点「完成 / 取消」。
|
||||
2. 打开 **系统设置 → 隐私与安全性**,下拉到「安全性」区域,会看到一条「已阻止使用 MacTFS」的提示,点 **仍要打开**。
|
||||
3. 再次确认,之后即可正常启动。
|
||||
|
||||
**方法二 · 终端去掉隔离属性(推荐,最省事)**
|
||||
|
||||
把应用拖进「应用程序」后,在终端执行下面这条命令,去掉 macOS 给下载文件打的隔离标记(quarantine):
|
||||
|
||||
```bash
|
||||
xattr -dr com.apple.quarantine /Applications/MacTFS.app
|
||||
```
|
||||
|
||||
执行后直接双击即可打开。若仍提示「已损坏」,再用这条(会清除全部扩展属性):
|
||||
|
||||
```bash
|
||||
sudo xattr -cr /Applications/MacTFS.app
|
||||
```
|
||||
|
||||
> 这些步骤只是绕过「未签名」拦截,应用本身不会因此被修改。介意签名/公证的话,需要 Apple 开发者账号自行签名。
|
||||
|
||||
---
|
||||
|
||||
## 为什么做它
|
||||
|
||||
- **macOS 上几乎没有顺手的 TFVC 客户端。** 微软官方的 Team Explorer Everywhere 只剩命令行 / 老 Eclipse 插件且基本停更;IntelliJ 的 TFS 插件又必须绑着 IDE。
|
||||
- **想要一个能独立运行、界面现代、对 Apple Silicon 友好的桌面工具。** 这就是 macTFS 的目标——为自己/小团队打造的「天天能用」的工具。
|
||||
- **顺带提供 HTTP API。** 让 TFS 操作能被脚本和 CI 复用,而不只是点界面。
|
||||
|
||||
---
|
||||
|
||||
## 核心功能
|
||||
|
||||
- **连接与认证**:配置 TFS 服务器地址、NTLM 账号密码,测试连接、保持会话。
|
||||
- **Collection / Workspace / Mapping**:选择项目集合,自动确保默认工作区,集中管理服务端路径 ↔ 本地路径映射。
|
||||
- **服务端目录浏览**:左侧目录树 + 中间文件列表,同步导航、右键菜单统一动作。
|
||||
- **文件同步**:Get Latest、获取指定版本 / Changeset。
|
||||
- **签出签入闭环**:Checkout / Checkin / Add / Delete / Undo,签入附注释。
|
||||
- **本地变更管理**:扫描并展示 Pending Changes,分组签入。
|
||||
- **历史与对比**:文件 / 路径历史、Changeset 详情、本地 vs 云端 Diff、版本间对比、目录对比。
|
||||
- **macOS 原生观感**:仿 Finder 的工作台、Sheet 风格弹窗与轻量动效。
|
||||
- **操作日志**:底部可折叠控制台实时展示操作与错误。
|
||||
|
||||
> 完整的需求与 API 清单见 [`docs/mactfs-prd.md`](docs/mactfs-prd.md)。
|
||||
|
||||
---
|
||||
|
||||
## 技术架构
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ 消费端:Electron UI │ curl │ 脚本 │ CI Pipeline │
|
||||
└──────────────────────────┬───────────────────────────────┘
|
||||
│ HTTP REST (localhost:38765)
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ mactfs-server(Java 8 + SparkJava) │
|
||||
│ HTTP 路由 · 会话管理 · 配置存储 · TFS 核心服务层 │
|
||||
└──────────────────────────┬───────────────────────────────┘
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ Microsoft TFS SDK(tfsIntegration/lib) │
|
||||
│ SOAP 通信 · NTLM 认证 · 版本控制 │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 层级 | 技术 |
|
||||
| --- | --- |
|
||||
| 前端 | Electron + React 19 + React Router 7 + shadcn/ui + Tailwind CSS 4 + Monaco |
|
||||
| 后端 | Java 8 + SparkJava(内嵌 HTTP 服务) |
|
||||
| TFS 内核 | Microsoft TFS Java SDK(SOAP / NTLM) |
|
||||
| 运行环境 | Zulu JDK 8 **x86_64** + Rosetta 2(见下方说明) |
|
||||
|
||||
> ⚠️ **为什么固定 x64 JDK:** TFS SDK 的 JNI 原生库只含 i386/x86_64,没有 arm64 切片。因此服务端 JVM 固定走 x64,在 Apple Silicon 上经 Rosetta 2 运行;Electron 界面本身仍是原生。
|
||||
|
||||
---
|
||||
|
||||
## 环境要求
|
||||
|
||||
- macOS 12+(Intel 或 Apple Silicon;Apple Silicon 需安装 Rosetta 2)
|
||||
- [pnpm](https://pnpm.io/)(前端包管理)
|
||||
- 首次构建需联网(自动下载 x64 JDK,见下)
|
||||
- 目标 TFS:TFS 2015 及以下(TFVC)
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 克隆
|
||||
|
||||
```bash
|
||||
git clone <你的仓库地址> mactfs
|
||||
cd mactfs/mactfsui
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 2. 准备 x64 JDK(自动)
|
||||
|
||||
服务端需要一个 x64 的 JDK 8。仓库**不**收录这个 200M+ 的二进制,而是按需从 Azul 官方下载并校验:
|
||||
|
||||
```bash
|
||||
cd mactfsui
|
||||
pnpm prepare:runtime
|
||||
```
|
||||
|
||||
脚本会检测本地是否已有 `zulu8.94.0.17-ca-jdk8.0.492-macosx_x64/`,没有就从 Azul 下载 → 校验 sha256 → 解压到仓库根目录。`pnpm dist` 会自动先跑这一步。
|
||||
|
||||
### 3. 构建后端
|
||||
|
||||
```bash
|
||||
cd mactfs
|
||||
../tfsIntegration/gradlew installDist
|
||||
```
|
||||
|
||||
> 后端从 `mactfs/build/install/mactfs/lib/` 加载运行,改了 Java 代码必须重新 `installDist`。
|
||||
|
||||
### 4. 开发模式运行(推荐)
|
||||
|
||||
```bash
|
||||
cd mactfsui
|
||||
pnpm electron:dev
|
||||
```
|
||||
|
||||
会同时拉起 vite 开发服务器和 Electron 窗口;Electron 发现后端没运行时会自动拉起它,退出时自动回收。
|
||||
|
||||
### 5. 打包成桌面应用(.app / .dmg)
|
||||
|
||||
```bash
|
||||
cd mactfsui
|
||||
pnpm dist
|
||||
```
|
||||
|
||||
产物输出到 `mactfsui/dist-app/`(universal DMG,内置精简后的 x64 JRE 与服务端 jar)。
|
||||
|
||||
> 启动 / 停止 / 重启 / 排错的完整说明见 [`服务启停指南.md`](服务启停指南.md)。
|
||||
|
||||
---
|
||||
|
||||
## 使用流程
|
||||
|
||||
1. **连接**:填入 TFS 服务器地址、认证方式(NTLM)、账号 / 域 / 密码,点测试连接。
|
||||
2. **选择 Collection**:登录成功后选择要操作的项目集合,系统自动确保默认 Workspace。
|
||||
3. **建立映射**:在目录树或文件列表上「映射到本地」,把服务端路径绑到本地目录(可选立即 Get Latest)。
|
||||
4. **同步文件**:对映射执行 Get Latest,或获取指定版本。
|
||||
5. **改 → 签出 → 签入**:编辑文件后在右侧 Changes 面板查看 Pending Changes,填注释签入。
|
||||
6. **历史 / 对比**:右键查看历史、Changeset 详情,或做本地 vs 云端、版本间、目录级的 Diff。
|
||||
|
||||
### 也可以直接调 API
|
||||
|
||||
服务监听 `127.0.0.1:38765`,token 存在 `~/.mactfs/server-token`:
|
||||
|
||||
```bash
|
||||
TOKEN=$(cat ~/.mactfs/server-token)
|
||||
curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:38765/api/health
|
||||
```
|
||||
|
||||
完整 API 清单见 [`docs/mactfs-prd.md`](docs/mactfs-prd.md) 第四章。
|
||||
|
||||
---
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
mydev/
|
||||
├── mactfs/ # Java 后端:CLI + 本地 HTTP API 服务
|
||||
├── mactfsui/ # Electron + React 桌面前端
|
||||
│ ├── app/ # 前端源码(页面、组件、API client)
|
||||
│ ├── electron/ # Electron 主进程 / preload
|
||||
│ └── scripts/ # 打包辅助脚本(prepare-runtime.mjs 等)
|
||||
├── tfsIntegration/ # 微软 TFS Java SDK 及原生库(构建依赖)
|
||||
├── docs/ # PRD、需求、截图等文档
|
||||
├── task/ # 分阶段开发任务单
|
||||
└── testing/ # 端到端 / 接口测试报告
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第三方依赖与许可
|
||||
|
||||
- 本项目通过 `tfsIntegration/` 下的 **Microsoft TFS Java SDK** 与 TFS 通信,其分发受微软相应许可约束。
|
||||
- x64 JDK 由 [Azul Zulu](https://www.azul.com/downloads/) 提供,按需下载,不随仓库分发。
|
||||
|
||||
> macTFS 是面向个人 / 小团队的工具型项目,主要解决「在 Mac 上用老 TFS」这一具体痛点。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 120 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 314 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 252 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 325 KiB |
@@ -5,3 +5,8 @@
|
||||
# React Router
|
||||
/.react-router/
|
||||
/build/
|
||||
|
||||
# electron-builder 打包产物与生成的运行时(体积大,不入库)
|
||||
/dist-app/
|
||||
/runtime/
|
||||
/build-resources/icon.iconset/
|
||||
|
||||
+8
-15
@@ -1,21 +1,14 @@
|
||||
# React Router + shadcn/ui
|
||||
# mactfsui
|
||||
|
||||
This is a template for a new React Router project with React, TypeScript, and shadcn/ui.
|
||||
macTFS 的 Electron + React 桌面前端。完整的项目介绍、截图、环境要求与使用说明请见仓库根目录的 [README](../README.md)。
|
||||
|
||||
## Adding components
|
||||
|
||||
To add components to your app, run the following command:
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add button
|
||||
pnpm install # 安装依赖
|
||||
pnpm electron:dev # 开发模式(vite + Electron,自动拉起后端)
|
||||
pnpm prepare:runtime # 准备 x64 JDK(本地缺失时自动从 Azul 下载)
|
||||
pnpm dist # 打包 macOS 桌面应用到 dist-app/
|
||||
```
|
||||
|
||||
This will place the ui components in the `components` directory.
|
||||
|
||||
## Using components
|
||||
|
||||
To use the components in your app, import them as follows:
|
||||
|
||||
```tsx
|
||||
import { Button } from "@/components/ui/button";
|
||||
```
|
||||
启动 / 停止 / 重启 / 排错见根目录的 [`服务启停指南.md`](../服务启停指南.md)。
|
||||
|
||||
+8
-44
@@ -2,6 +2,8 @@
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
/* 应用只有亮色主题:保留 class 方案的 dark 变体定义,使组件库模板中残留的
|
||||
dark: 工具类永远不会命中(根节点不再挂 .dark),避免回退到系统媒体查询。 */
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/*
|
||||
@@ -53,7 +55,7 @@
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
/* 阴影分层:卡片极轻 / 浮层轻 / 弹窗(Sheet)明显 */
|
||||
--shadow-card: 0 1px 2px rgb(0 0 0 / 0.05);
|
||||
--shadow-card: 0 0 0 0.5px rgb(0 0 0 / 0.03), 0 1px 1px rgb(0 0 0 / 0.04), 0 4px 14px rgb(0 0 0 / 0.06);
|
||||
--shadow-overlay: 0 4px 16px rgb(0 0 0 / 0.1), 0 1px 3px rgb(0 0 0 / 0.06);
|
||||
--shadow-dialog: 0 18px 50px rgb(0 0 0 / 0.22), 0 4px 12px rgb(0 0 0 / 0.1);
|
||||
/* 缓动曲线 */
|
||||
@@ -111,40 +113,6 @@
|
||||
--motion-slow: 320ms;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.21 0.005 285);
|
||||
--foreground: oklch(0.96 0.002 286);
|
||||
--card: oklch(0.245 0.006 285);
|
||||
--card-foreground: oklch(0.96 0.002 286);
|
||||
--popover: oklch(0.245 0.006 285);
|
||||
--popover-foreground: oklch(0.96 0.002 286);
|
||||
/* macOS 暗色系统蓝(≈ #0A84FF) */
|
||||
--primary: oklch(0.64 0.19 253);
|
||||
--primary-foreground: oklch(0.99 0.002 286);
|
||||
--secondary: oklch(0.29 0.006 286);
|
||||
--secondary-foreground: oklch(0.96 0.002 286);
|
||||
--muted: oklch(0.28 0.006 286);
|
||||
--muted-foreground: oklch(0.71 0.01 286);
|
||||
--accent: oklch(0.3 0.006 286);
|
||||
--accent-foreground: oklch(0.96 0.002 286);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 9%);
|
||||
--input: oklch(1 0 0 / 13%);
|
||||
--ring: oklch(0.64 0.19 253);
|
||||
--chart-1: oklch(0.809 0.105 251.813);
|
||||
--chart-2: oklch(0.7 0.165 254);
|
||||
--chart-3: oklch(0.64 0.19 253);
|
||||
--chart-4: oklch(0.5 0.21 259);
|
||||
--chart-5: oklch(0.43 0.19 262);
|
||||
--sidebar: oklch(0.22 0.005 285 / 62%);
|
||||
--sidebar-foreground: oklch(0.96 0.002 286);
|
||||
--sidebar-primary: oklch(0.64 0.19 253);
|
||||
--sidebar-primary-foreground: oklch(0.99 0.002 286);
|
||||
--sidebar-accent: oklch(0.36 0.006 285 / 80%);
|
||||
--sidebar-border: oklch(1 0 0 / 9%);
|
||||
--sidebar-ring: oklch(0.64 0.19 253);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
@@ -157,21 +125,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 主题切换瞬间的全局颜色过渡,由 use-theme 短暂挂载 theme-transition class。 */
|
||||
.theme-transition,
|
||||
.theme-transition * {
|
||||
transition:
|
||||
background-color 250ms var(--ease-out-quart),
|
||||
border-color 250ms var(--ease-out-quart),
|
||||
color 250ms var(--ease-out-quart) !important;
|
||||
}
|
||||
|
||||
/* vibrancy 模式(FE-027 在 macOS Electron 下挂载):页面底透明,毛玻璃从半透明侧栏透出 */
|
||||
html.vibrancy,
|
||||
html.vibrancy body {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 工作台画布底色:比卡片深一档的中性灰,让白色卡片浮起;vibrancy 下半透明透出窗口毛玻璃。 */
|
||||
@utility canvas-surface {
|
||||
background-color: oklch(0.934 0.004 286 / 0.85);
|
||||
}
|
||||
|
||||
/* 隐藏式标题栏下的窗口拖拽区域:顶栏整体可拖动,交互控件单独豁免。 */
|
||||
@utility app-drag {
|
||||
-webkit-app-region: drag;
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
import { FolderCog, Monitor, Moon, PanelBottom, PanelLeft, PanelRight, Sun, Unplug } from "lucide-react"
|
||||
import { FolderCog, PanelBottom, PanelLeft, PanelRight, Unplug } from "lucide-react"
|
||||
|
||||
import { Button } from "~/components/ui/button"
|
||||
import { Separator } from "~/components/ui/separator"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip"
|
||||
import { useTheme, type ThemePreference } from "~/hooks/use-theme"
|
||||
import { isMacElectron } from "~/lib/platform"
|
||||
import type { WorkspaceSession } from "~/lib/tfs/session"
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
// 主题三态对应的图标与说明文案。
|
||||
const THEME_VISUALS: Record<ThemePreference, { icon: typeof Monitor; label: string }> = {
|
||||
system: { icon: Monitor, label: "主题:跟随系统" },
|
||||
light: { icon: Sun, label: "主题:亮色" },
|
||||
dark: { icon: Moon, label: "主题:暗色" },
|
||||
}
|
||||
|
||||
// 三个可折叠面板的显隐状态,由 WorkspaceShell 持有。
|
||||
export interface PanelVisibility {
|
||||
tree: boolean
|
||||
@@ -23,8 +15,8 @@ export interface PanelVisibility {
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶部上下文栏:展示固定的 Server / Collection / Workspace 上下文,
|
||||
* 以及面板折叠开关与重新连接入口,不承载对象级操作。
|
||||
* 顶部上下文栏:仿 Finder 的居中上下文胶囊展示 Collection / Workspace
|
||||
* (悬停查看服务器地址),右侧为面板折叠开关与重新连接入口,不承载对象级操作。
|
||||
*/
|
||||
export function TopBar({
|
||||
session,
|
||||
@@ -41,9 +33,6 @@ export function TopBar({
|
||||
}) {
|
||||
// 隐藏式标题栏下顶栏整体作为拖拽区,并为左上角红绿灯预留空间。
|
||||
const macInset = isMacElectron()
|
||||
const theme = useTheme()
|
||||
const themeVisual = THEME_VISUALS[theme.preference]
|
||||
const ThemeIcon = themeVisual.icon
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
@@ -51,21 +40,26 @@ export function TopBar({
|
||||
macInset && "pl-20",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm">
|
||||
<span className="shrink-0 font-semibold">MacTFS</span>
|
||||
<Separator orientation="vertical" className="h-4!" />
|
||||
<span className="hidden shrink-0 text-xs text-muted-foreground lg:inline">
|
||||
服务器
|
||||
</span>
|
||||
<span className="hidden max-w-56 truncate font-mono text-xs lg:inline">
|
||||
{session.serverUri}
|
||||
</span>
|
||||
<Separator orientation="vertical" className="hidden h-4! lg:block" />
|
||||
<span className="shrink-0 text-xs text-muted-foreground">Collection</span>
|
||||
<span className="max-w-40 truncate text-xs font-medium">{session.collection}</span>
|
||||
<Separator orientation="vertical" className="h-4!" />
|
||||
<span className="shrink-0 text-xs text-muted-foreground">Workspace</span>
|
||||
<span className="max-w-44 truncate font-mono text-xs">{session.workspace}</span>
|
||||
{/* 左侧:程序名 + Collection 胶囊;服务器地址与工作区收进悬停提示 */}
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<span className="shrink-0 text-sm font-medium">MacTFS</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="app-no-drag flex h-7 min-w-0 items-center rounded-lg bg-foreground/5 px-3 text-xs text-muted-foreground transition-colors hover:bg-foreground/8 hover:text-foreground">
|
||||
<span className="truncate">{session.collection}</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
<div className="space-y-0.5">
|
||||
<p>
|
||||
服务器 <span className="font-mono">{session.serverUri}</span>
|
||||
</p>
|
||||
<p>
|
||||
工作区 <span className="font-mono">{session.workspace}</span>
|
||||
</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="app-no-drag flex shrink-0 items-center gap-1">
|
||||
@@ -91,14 +85,6 @@ export function TopBar({
|
||||
<PanelBottom />
|
||||
</PanelToggle>
|
||||
<Separator orientation="vertical" className="mx-1 h-4!" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="icon-sm" variant="ghost" onClick={theme.cycle} aria-label={themeVisual.label}>
|
||||
<ThemeIcon />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{themeVisual.label}(点击切换)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="icon-sm" variant="ghost" onClick={onManageWorkspace} aria-label="工作区与映射管理">
|
||||
|
||||
@@ -88,7 +88,7 @@ export function WorkspaceShell({
|
||||
return (
|
||||
<TooltipProvider>
|
||||
{/* 毛玻璃画布 + 卡片化面板:半透明画布透出 vibrancy,四个面板为圆角卡片留缝隙 */}
|
||||
<div className="flex h-svh flex-col overflow-hidden bg-sidebar">
|
||||
<div className="canvas-surface flex h-svh flex-col overflow-hidden">
|
||||
<TopBar
|
||||
session={session}
|
||||
panels={panels}
|
||||
|
||||
@@ -167,7 +167,7 @@ export function ConflictDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Badge variant="secondary" className="rounded-md bg-red-500/10 text-red-700 dark:text-red-400">
|
||||
<Badge variant="secondary" className="rounded-md bg-red-500/10 text-red-700">
|
||||
{conflicts.length} 个冲突
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -46,13 +46,6 @@ function splitDiffSides(raw: string[]): { original: string; modified: string } {
|
||||
return { original: original.join("\n"), modified: modified.join("\n") }
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前应用是否处于暗色主题(class 方案),用于选择 Monaco 主题。
|
||||
*/
|
||||
function isDarkTheme(): boolean {
|
||||
return typeof document !== "undefined" && document.documentElement.classList.contains("dark")
|
||||
}
|
||||
|
||||
/**
|
||||
* 文本 Diff 弹窗:基于 Monaco DiffEditor 渲染,支持 本地 vs 服务器 latest
|
||||
* 与 两个历史版本 对比。提供统一 / 左右分栏视图、上一处 / 下一处差异导航、
|
||||
@@ -126,7 +119,6 @@ export function DiffDialog({
|
||||
let diffEditor: MonacoEditor.IStandaloneDiffEditor | null = null
|
||||
let originalModel: MonacoEditor.ITextModel | null = null
|
||||
let modifiedModel: MonacoEditor.ITextModel | null = null
|
||||
let themeObserver: MutationObserver | null = null
|
||||
void (async () => {
|
||||
const { monaco, detectLanguage } = await import("~/lib/monaco")
|
||||
if (disposed || !containerRef.current) {
|
||||
@@ -144,7 +136,7 @@ export function DiffDialog({
|
||||
// 统一视图下隐藏旧版行号列,避免出现两列紧贴的行号。
|
||||
compactMode: viewMode === "unified",
|
||||
hideUnchangedRegions: { enabled: onlyChanges },
|
||||
theme: isDarkTheme() ? "mactfs-dark" : "mactfs-light",
|
||||
theme: "mactfs-light",
|
||||
fontSize: 12,
|
||||
lineHeight: 20,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, "Courier New", monospace',
|
||||
@@ -168,16 +160,10 @@ export function DiffDialog({
|
||||
setBlockCount(diffEditor?.getLineChanges()?.length ?? 0)
|
||||
})
|
||||
editorRef.current = diffEditor
|
||||
// 弹窗打开期间应用切换明暗主题时,同步更新 Monaco 主题。
|
||||
themeObserver = new MutationObserver(() => {
|
||||
monaco.editor.setTheme(isDarkTheme() ? "mactfs-dark" : "mactfs-light")
|
||||
})
|
||||
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] })
|
||||
})()
|
||||
return () => {
|
||||
disposed = true
|
||||
editorRef.current = null
|
||||
themeObserver?.disconnect()
|
||||
diffEditor?.dispose()
|
||||
originalModel?.dispose()
|
||||
modifiedModel?.dispose()
|
||||
@@ -257,10 +243,10 @@ export function DiffDialog({
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 text-xs">
|
||||
{diff && (
|
||||
<>
|
||||
<Badge variant="secondary" className="rounded-md bg-red-500/10 text-red-700 dark:text-red-400">
|
||||
<Badge variant="secondary" className="rounded-md bg-red-500/10 text-red-700">
|
||||
− {diff.sourceLabel}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="rounded-md bg-green-500/10 text-green-700 dark:text-green-400">
|
||||
<Badge variant="secondary" className="rounded-md bg-green-500/10 text-green-700">
|
||||
+ {diff.targetLabel}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground">{changedCount} 行差异</span>
|
||||
|
||||
@@ -21,42 +21,42 @@ const FILE_ICON_RULES: { exts: string[]; icon: LucideIcon; className: string }[]
|
||||
{
|
||||
exts: ["ts", "tsx", "js", "jsx", "java", "cs", "py", "go", "rs", "c", "cpp", "h", "vue", "css", "scss", "html"],
|
||||
icon: FileCode,
|
||||
className: "text-blue-600 dark:text-blue-400",
|
||||
className: "text-blue-600",
|
||||
},
|
||||
{
|
||||
exts: ["json", "xml", "yml", "yaml", "toml", "config", "properties", "gradle"],
|
||||
icon: FileJson,
|
||||
className: "text-amber-600 dark:text-amber-400",
|
||||
className: "text-amber-600",
|
||||
},
|
||||
{
|
||||
exts: ["md", "txt", "doc", "docx", "pdf", "rtf"],
|
||||
icon: FileText,
|
||||
className: "text-zinc-500 dark:text-zinc-400",
|
||||
className: "text-zinc-500",
|
||||
},
|
||||
{
|
||||
exts: ["sql", "db"],
|
||||
icon: Database,
|
||||
className: "text-violet-600 dark:text-violet-400",
|
||||
className: "text-violet-600",
|
||||
},
|
||||
{
|
||||
exts: ["png", "jpg", "jpeg", "gif", "svg", "webp", "ico", "icns"],
|
||||
icon: FileImage,
|
||||
className: "text-emerald-600 dark:text-emerald-400",
|
||||
className: "text-emerald-600",
|
||||
},
|
||||
{
|
||||
exts: ["zip", "rar", "7z", "tar", "gz", "jar"],
|
||||
icon: FileArchive,
|
||||
className: "text-orange-600 dark:text-orange-400",
|
||||
className: "text-orange-600",
|
||||
},
|
||||
{
|
||||
exts: ["xls", "xlsx", "csv"],
|
||||
icon: FileSpreadsheet,
|
||||
className: "text-green-600 dark:text-green-400",
|
||||
className: "text-green-600",
|
||||
},
|
||||
{
|
||||
exts: ["sh", "bat", "cmd", "ps1", "zsh"],
|
||||
icon: FileTerminal,
|
||||
className: "text-zinc-600 dark:text-zinc-300",
|
||||
className: "text-zinc-600",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -91,7 +91,7 @@ export function FileIcon({
|
||||
<Icon
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
mapped ? "text-primary" : "text-sky-600 dark:text-sky-400",
|
||||
mapped ? "text-primary" : "text-sky-600",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -129,7 +129,7 @@ export function FolderItemsPanel({
|
||||
|
||||
return (
|
||||
<section className="flex h-full min-w-0 flex-1 flex-col overflow-hidden rounded-xl border bg-card shadow-card">
|
||||
<div className="flex h-9 shrink-0 items-center gap-2 border-b px-3">
|
||||
<div className="flex h-9 shrink-0 items-center gap-2 border-b bg-muted/40 px-3">
|
||||
<span className="shrink-0 text-xs text-muted-foreground">当前路径</span>
|
||||
<span className="min-w-0 truncate font-mono text-xs">{selectedServerPath}</span>
|
||||
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
|
||||
@@ -172,7 +172,7 @@ export function FolderItemsPanel({
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-card">
|
||||
<TableHeader className="sticky top-0 z-10 bg-[oklch(0.972_0.003_286)]">
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-8 text-xs text-muted-foreground">名称</TableHead>
|
||||
<TableHead className="h-8 w-24 text-xs text-muted-foreground">状态</TableHead>
|
||||
|
||||
@@ -130,7 +130,7 @@ export function SourceTreePanel({
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-[280px] shrink-0 flex-col overflow-hidden rounded-xl border bg-card shadow-card">
|
||||
<div className="flex h-9 shrink-0 items-center gap-2 border-b px-3">
|
||||
<div className="flex h-9 shrink-0 items-center gap-2 border-b bg-muted/40 px-3">
|
||||
<span className="text-[11px] font-medium tracking-wider text-muted-foreground uppercase">
|
||||
源码目录
|
||||
</span>
|
||||
@@ -216,6 +216,7 @@ function TreeNode({
|
||||
isSelected ? "bg-sidebar-accent" : "hover:bg-sidebar-accent/60",
|
||||
)}
|
||||
style={{ paddingLeft: `${depth * 14 + 2}px` }}
|
||||
title={path}
|
||||
>
|
||||
<span className="flex size-5 shrink-0 items-center justify-center">
|
||||
{isLoading ? (
|
||||
|
||||
@@ -185,7 +185,7 @@ export function WorkspaceManageDialog({
|
||||
) : (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-red-500/10 text-red-700 dark:text-red-400"
|
||||
className="bg-red-500/10 text-red-700"
|
||||
>
|
||||
不存在
|
||||
</Badge>
|
||||
|
||||
@@ -67,7 +67,7 @@ export function ChangesPanel({
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-[340px] shrink-0 flex-col overflow-hidden rounded-xl border bg-card shadow-card">
|
||||
<div className="flex h-9 shrink-0 items-center gap-2 border-b px-3">
|
||||
<div className="flex h-9 shrink-0 items-center gap-2 border-b bg-muted/40 px-3">
|
||||
<span className="text-[11px] font-medium tracking-wider text-muted-foreground uppercase">
|
||||
挂起更改
|
||||
</span>
|
||||
|
||||
@@ -61,7 +61,7 @@ export function ConsolePanel({
|
||||
|
||||
return (
|
||||
<footer className="flex h-full flex-col overflow-hidden rounded-xl border bg-card shadow-card">
|
||||
<div className="flex h-8 shrink-0 items-center gap-2 border-b px-3">
|
||||
<div className="flex h-8 shrink-0 items-center gap-2 border-b bg-muted/40 px-3">
|
||||
<span className="text-[11px] font-medium tracking-wider text-muted-foreground uppercase">
|
||||
操作日志
|
||||
</span>
|
||||
|
||||
@@ -16,7 +16,9 @@ function ScrollArea({
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||
// Radix 默认给内容包一层 display:table + min-width:100% 的 div,会让内容按
|
||||
// 自身宽度撑开导致 truncate 失效、文字被面板边缘硬切;改为 block 占满视口宽度。
|
||||
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 [&>div]:block! [&>div]:w-full [&>div]:min-w-0!"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
|
||||
@@ -55,7 +55,7 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-primary/8 data-[state=selected]:hover:bg-primary/10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { AlertCircle, CheckCircle2, Loader2 } from "lucide-react"
|
||||
import { Check, Loader2, X } from "lucide-react"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
@@ -70,34 +70,15 @@ export function showToast(input: { id?: string; kind: ToastKind; text: string })
|
||||
return id
|
||||
}
|
||||
|
||||
// toast 类型对应的图标、强调条与边框配色:彩色左侧条 + 着色边框提高辨识度。
|
||||
const KIND_VISUALS: Record<
|
||||
ToastKind,
|
||||
{ icon: typeof CheckCircle2; iconClass: string; barClass: string; borderClass: string }
|
||||
> = {
|
||||
success: {
|
||||
icon: CheckCircle2,
|
||||
iconClass: "text-emerald-600 dark:text-emerald-400",
|
||||
barClass: "bg-emerald-500",
|
||||
borderClass: "border-emerald-500/35",
|
||||
},
|
||||
error: {
|
||||
icon: AlertCircle,
|
||||
iconClass: "text-destructive",
|
||||
barClass: "bg-destructive",
|
||||
borderClass: "border-destructive/40",
|
||||
},
|
||||
loading: {
|
||||
icon: Loader2,
|
||||
iconClass: "animate-spin text-primary",
|
||||
barClass: "bg-primary",
|
||||
borderClass: "border-primary/35",
|
||||
},
|
||||
// toast 类型对应的状态徽标:macOS 通知风的彩色小圆徽(成功绿 / 失败红),loading 用旋转图标。
|
||||
const KIND_BADGES: Record<"success" | "error", { icon: typeof Check; chipClass: string }> = {
|
||||
success: { icon: Check, chipClass: "bg-emerald-500" },
|
||||
error: { icon: X, chipClass: "bg-destructive" },
|
||||
}
|
||||
|
||||
/**
|
||||
* toast 挂载点:固定在窗口右上角(顶栏下方),新条目自上滑入,点击可关闭。
|
||||
* 彩色强调条 + 着色边框 + 毛玻璃底,保证进度与结果提示足够显眼。
|
||||
* 视觉对齐 macOS 系统通知:白色毛玻璃圆角卡片 + 彩色状态圆徽 + 柔和投影,无彩色边框。
|
||||
*/
|
||||
export function Toaster() {
|
||||
const [items, setItems] = useState<ToastItem[]>([])
|
||||
@@ -116,26 +97,42 @@ export function Toaster() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed top-14 right-3 z-[60] flex w-[380px] flex-col gap-2">
|
||||
{items.map((item) => {
|
||||
const visual = KIND_VISUALS[item.kind]
|
||||
const Icon = visual.icon
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => dismissToast(item.id)}
|
||||
className={cn(
|
||||
"animate-in fade-in slide-in-from-top-3 ease-out-quart pointer-events-auto relative flex items-start gap-2.5 overflow-hidden rounded-xl border bg-popover/95 py-3 pr-3.5 pl-4 text-left text-sm font-medium shadow-overlay backdrop-blur-md duration-250",
|
||||
visual.borderClass,
|
||||
)}
|
||||
>
|
||||
<span className={cn("absolute inset-y-0 left-0 w-1", visual.barClass)} />
|
||||
<Icon className={cn("mt-0.5 size-4.5 shrink-0", visual.iconClass)} />
|
||||
<span className="min-w-0 break-words">{item.text}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<div className="pointer-events-none fixed top-14 right-4 z-[60] flex flex-col items-end gap-2.5">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => dismissToast(item.id)}
|
||||
className="animate-in fade-in slide-in-from-top-2 ease-out-quart pointer-events-auto flex w-fit max-w-[360px] min-w-[200px] items-center gap-2.5 rounded-xl border border-black/8 bg-popover/90 py-2.5 pr-4 pl-3 text-left shadow-[0_8px_24px_rgb(0_0_0/0.12),0_2px_6px_rgb(0_0_0/0.06)] backdrop-blur-xl duration-300"
|
||||
>
|
||||
{item.kind === "loading" ? (
|
||||
<Loader2 className="size-4.5 shrink-0 animate-spin text-primary" />
|
||||
) : (
|
||||
<ToastBadge kind={item.kind} />
|
||||
)}
|
||||
<span className="min-w-0 text-[13px] leading-snug font-medium break-words text-foreground">
|
||||
{item.text}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 成功 / 失败的彩色圆形徽标:实色圆底 + 白色粗描边图标。
|
||||
*/
|
||||
function ToastBadge({ kind }: { kind: "success" | "error" }) {
|
||||
const badge = KIND_BADGES[kind]
|
||||
const Icon = badge.icon
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-5 shrink-0 items-center justify-center rounded-full",
|
||||
badge.chipClass,
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3 text-white" strokeWidth={3.2} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
|
||||
// 主题偏好:跟随系统 / 强制亮色 / 强制暗色。
|
||||
export type ThemePreference = "system" | "light" | "dark"
|
||||
|
||||
const STORAGE_KEY = "mactfs.theme"
|
||||
const CYCLE_ORDER: ThemePreference[] = ["system", "light", "dark"]
|
||||
|
||||
/**
|
||||
* 当前系统外观是否为暗色。
|
||||
*/
|
||||
function systemPrefersDark(): boolean {
|
||||
return typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
}
|
||||
|
||||
/**
|
||||
* 由偏好推导是否应启用暗色。
|
||||
*/
|
||||
function resolveIsDark(preference: ThemePreference): boolean {
|
||||
return preference === "dark" || (preference === "system" && systemPrefersDark())
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取持久化的主题偏好,无记录或不可用时默认跟随系统。
|
||||
*/
|
||||
function loadPreference(): ThemePreference {
|
||||
if (typeof window === "undefined") {
|
||||
return "system"
|
||||
}
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||
return raw === "light" || raw === "dark" ? raw : "system"
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用主题到根节点:切换 .dark class,并短暂启用全局颜色过渡动画。
|
||||
*/
|
||||
function applyTheme(preference: ThemePreference) {
|
||||
const root = document.documentElement
|
||||
root.classList.add("theme-transition")
|
||||
root.classList.toggle("dark", resolveIsDark(preference))
|
||||
window.setTimeout(() => root.classList.remove("theme-transition"), 320)
|
||||
}
|
||||
|
||||
/**
|
||||
* 明暗主题 hook:维护「跟随系统 / 亮 / 暗」三态偏好,持久化到 localStorage,
|
||||
* 跟随系统时监听系统外观变化自动切换。
|
||||
*/
|
||||
export function useTheme() {
|
||||
const [preference, setPreferenceState] = useState<ThemePreference>(loadPreference)
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(preference)
|
||||
if (preference !== "system") {
|
||||
return
|
||||
}
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
const onChange = () => applyTheme("system")
|
||||
media.addEventListener("change", onChange)
|
||||
return () => media.removeEventListener("change", onChange)
|
||||
}, [preference])
|
||||
|
||||
/**
|
||||
* 设置主题偏好并持久化。
|
||||
*/
|
||||
const setPreference = useCallback((next: ThemePreference) => {
|
||||
setPreferenceState(next)
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, next)
|
||||
} catch {
|
||||
// 本地存储不可用时仅在当前会话生效
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 按 跟随系统 → 亮色 → 暗色 顺序循环切换。
|
||||
*/
|
||||
const cycle = useCallback(() => {
|
||||
const index = CYCLE_ORDER.indexOf(preference)
|
||||
setPreference(CYCLE_ORDER[(index + 1) % CYCLE_ORDER.length])
|
||||
}, [preference, setPreference])
|
||||
|
||||
return { preference, setPreference, cycle }
|
||||
}
|
||||
@@ -54,34 +54,6 @@ monaco.editor.defineTheme("mactfs-light", {
|
||||
},
|
||||
})
|
||||
|
||||
monaco.editor.defineTheme("mactfs-dark", {
|
||||
base: "vs-dark",
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: {
|
||||
"editor.background": "#1b1b1b",
|
||||
"editor.foreground": "#fafafa",
|
||||
"editor.lineHighlightBackground": "#ffffff08",
|
||||
"editorLineNumber.foreground": "#6b6b6b",
|
||||
"editorLineNumber.activeForeground": "#a3a3a3",
|
||||
"editorCursor.foreground": "#818cf8",
|
||||
"focusBorder": "#00000000",
|
||||
"diffEditor.insertedTextBackground": "#22c55e30",
|
||||
"diffEditor.insertedLineBackground": "#22c55e17",
|
||||
"diffEditor.removedTextBackground": "#ef444430",
|
||||
"diffEditor.removedLineBackground": "#ef444417",
|
||||
"diffEditorGutter.insertedLineBackground": "#22c55e24",
|
||||
"diffEditorGutter.removedLineBackground": "#ef444424",
|
||||
"diffEditorOverview.insertedForeground": "#22c55e99",
|
||||
"diffEditorOverview.removedForeground": "#ef444499",
|
||||
"diffEditor.unchangedRegionBackground": "#222222",
|
||||
"diffEditor.unchangedRegionForeground": "#9c9c9c",
|
||||
"scrollbarSlider.background": "#ffffff1f",
|
||||
"scrollbarSlider.hoverBackground": "#ffffff33",
|
||||
"scrollbarSlider.activeBackground": "#ffffff40",
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* 按文件路径后缀匹配 Monaco 已注册语言,匹配不到时回退纯文本。
|
||||
*/
|
||||
|
||||
@@ -21,18 +21,18 @@ export const STATUS_LABELS: Record<string, string> = {
|
||||
|
||||
// 业务状态对应的 badge 配色(Tailwind class),按 Agents.md 状态色规则。
|
||||
export const STATUS_BADGE_CLASSES: Record<string, string> = {
|
||||
localModified: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
|
||||
remoteChanged: "bg-violet-500/10 text-violet-700 dark:text-violet-400",
|
||||
bothChanged: "bg-red-500/15 text-red-700 dark:text-red-400",
|
||||
localOnly: "bg-green-500/10 text-green-700 dark:text-green-400",
|
||||
remoteOnly: "bg-violet-500/10 text-violet-700 dark:text-violet-400",
|
||||
notDownloaded: "bg-amber-500/10 text-amber-700 dark:text-amber-400",
|
||||
localDeleted: "bg-red-500/10 text-red-700 dark:text-red-400",
|
||||
pendingEdit: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
|
||||
pendingAdd: "bg-green-500/10 text-green-700 dark:text-green-400",
|
||||
pendingDelete: "bg-red-500/10 text-red-700 dark:text-red-400",
|
||||
pendingRename: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
|
||||
pending: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
|
||||
localModified: "bg-blue-500/10 text-blue-700",
|
||||
remoteChanged: "bg-violet-500/10 text-violet-700",
|
||||
bothChanged: "bg-red-500/15 text-red-700",
|
||||
localOnly: "bg-green-500/10 text-green-700",
|
||||
remoteOnly: "bg-violet-500/10 text-violet-700",
|
||||
notDownloaded: "bg-amber-500/10 text-amber-700",
|
||||
localDeleted: "bg-red-500/10 text-red-700",
|
||||
pendingEdit: "bg-blue-500/10 text-blue-700",
|
||||
pendingAdd: "bg-green-500/10 text-green-700",
|
||||
pendingDelete: "bg-red-500/10 text-red-700",
|
||||
pendingRename: "bg-blue-500/10 text-blue-700",
|
||||
pending: "bg-blue-500/10 text-blue-700",
|
||||
upToDate: "bg-muted text-muted-foreground",
|
||||
notMapped: "bg-muted text-muted-foreground",
|
||||
mapped: "bg-muted text-foreground/80",
|
||||
|
||||
@@ -16,10 +16,10 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
{/* 首屏前标记 vibrancy 环境并应用主题偏好,避免暗色闪白 */}
|
||||
{/* 首屏前标记 vibrancy 环境(macOS Electron 下透出窗口毛玻璃) */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(function(){try{var ua=navigator.userAgent;if(ua.includes("Electron")&&ua.includes("Macintosh"))document.documentElement.classList.add("vibrancy");var t=localStorage.getItem("mactfs.theme");var d=t==="dark"||(t!=="light"&&matchMedia("(prefers-color-scheme: dark)").matches);if(d)document.documentElement.classList.add("dark")}catch(e){}})()`,
|
||||
__html: `(function(){try{var ua=navigator.userAgent;if(ua.includes("Electron")&&ua.includes("Macintosh"))document.documentElement.classList.add("vibrancy")}catch(e){}})()`,
|
||||
}}
|
||||
/>
|
||||
<Meta />
|
||||
|
||||
Binary file not shown.
+92
-17
@@ -1,4 +1,4 @@
|
||||
const { app, BrowserWindow, ipcMain, dialog, shell, nativeImage, nativeTheme } = require("electron")
|
||||
const { app, BrowserWindow, ipcMain, dialog, shell, nativeImage, protocol } = require("electron")
|
||||
const path = require("node:path")
|
||||
const fs = require("node:fs")
|
||||
const os = require("node:os")
|
||||
@@ -14,6 +14,58 @@ const HEALTH_TIMEOUT_MS = 2000
|
||||
const START_POLL_TIMES = 30
|
||||
const START_POLL_INTERVAL_MS = 1000
|
||||
|
||||
// 自定义协议:以 app://bundle/ 提供前端静态资源。
|
||||
// 打包后构建产物使用绝对路径(/assets/...),用 file:// 会指向磁盘根目录导致白屏,故走自定义协议从 build/client 映射。
|
||||
const APP_SCHEME = "app"
|
||||
const APP_ORIGIN = `${APP_SCHEME}://bundle/`
|
||||
const MIME_BY_EXT = {
|
||||
".html": "text/html",
|
||||
".js": "text/javascript",
|
||||
".mjs": "text/javascript",
|
||||
".css": "text/css",
|
||||
".json": "application/json",
|
||||
".map": "application/json",
|
||||
".ico": "image/x-icon",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".svg": "image/svg+xml",
|
||||
".webp": "image/webp",
|
||||
".ttf": "font/ttf",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".wasm": "application/wasm",
|
||||
}
|
||||
|
||||
// 必须在 app ready 之前注册自定义协议的特权(标准协议 + 安全上下文)。
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: APP_SCHEME,
|
||||
privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true },
|
||||
},
|
||||
])
|
||||
|
||||
/**
|
||||
* 注册 app:// 协议处理器:把请求路径映射到 build/client 下的静态文件,找不到时回退到 index.html(适配前端路由)。
|
||||
*/
|
||||
function registerAppProtocol() {
|
||||
const clientRoot = path.join(__dirname, "..", "build", "client")
|
||||
protocol.handle(APP_SCHEME, async (request) => {
|
||||
let relativePath = decodeURIComponent(new URL(request.url).pathname)
|
||||
if (!relativePath || relativePath === "/") {
|
||||
relativePath = "/index.html"
|
||||
}
|
||||
let filePath = path.normalize(path.join(clientRoot, relativePath))
|
||||
if (!filePath.startsWith(clientRoot) || !fs.existsSync(filePath)) {
|
||||
filePath = path.join(clientRoot, "index.html")
|
||||
}
|
||||
const data = await fs.promises.readFile(filePath)
|
||||
const contentType = MIME_BY_EXT[path.extname(filePath).toLowerCase()] || "application/octet-stream"
|
||||
return new Response(data, { headers: { "content-type": contentType } })
|
||||
})
|
||||
}
|
||||
|
||||
// 记录由本进程拉起的服务端子进程,便于应用退出时回收。
|
||||
let serverProcess = null
|
||||
|
||||
@@ -24,19 +76,30 @@ function resolveProjectRoot() {
|
||||
return path.resolve(__dirname, "..", "..")
|
||||
}
|
||||
|
||||
// 服务端 JVM 固定使用 x64:TFS 的 JNI 原生库(libnative_*.jnilib)只含 i386/x86_64,无 arm64 切片,
|
||||
// 用 arm64 JVM 加载会 UnsatisfiedLinkError。Apple Silicon 上 x64 JVM 经 Rosetta 2 运行;
|
||||
// 服务端是 IO 密集型,Rosetta 开销可接受。UI(Electron)走 universal 原生,已解决界面卡顿。
|
||||
const PACKAGED_JRE_DIR = "jre-x64"
|
||||
const DEV_JDK_DIR = "zulu8.94.0.17-ca-jdk8.0.492-macosx_x64"
|
||||
|
||||
/**
|
||||
* 解析运行服务端使用的 Java 可执行文件,优先项目内置 zulu8,其次 JAVA_HOME,最后 PATH 上的 java。
|
||||
* 解析运行服务端使用的 Java 可执行文件:固定 x64 内置 zulu8,其次 JAVA_HOME,最后 PATH 上的 java。
|
||||
*/
|
||||
function resolveJavaBin() {
|
||||
// 打包态:x64 JRE 随 extraResources 落在 Resources/jre-x64 下。
|
||||
if (app.isPackaged) {
|
||||
const packaged = path.join(process.resourcesPath, PACKAGED_JRE_DIR, "bin", "java")
|
||||
if (fs.existsSync(packaged)) {
|
||||
return packaged
|
||||
}
|
||||
// 兼容旧单架构包结构(Resources/jre)。
|
||||
const legacy = path.join(process.resourcesPath, "jre", "bin", "java")
|
||||
if (fs.existsSync(legacy)) {
|
||||
return legacy
|
||||
}
|
||||
}
|
||||
const projectRoot = resolveProjectRoot()
|
||||
const bundled = path.join(
|
||||
projectRoot,
|
||||
"zulu8.94.0.17-ca-jdk8.0.492-macosx_x64",
|
||||
"Contents",
|
||||
"Home",
|
||||
"bin",
|
||||
"java",
|
||||
)
|
||||
const bundled = path.join(projectRoot, DEV_JDK_DIR, "Contents", "Home", "bin", "java")
|
||||
if (fs.existsSync(bundled)) {
|
||||
return bundled
|
||||
}
|
||||
@@ -53,6 +116,13 @@ function resolveJavaBin() {
|
||||
* 解析服务端 classpath,优先使用 application 安装包 lib 目录(已包含全部运行依赖)。
|
||||
*/
|
||||
function resolveServerClasspath() {
|
||||
// 打包态:服务端 jar 随 extraResources 落在 Resources/server/lib 下。
|
||||
if (app.isPackaged) {
|
||||
const packagedLib = path.join(process.resourcesPath, "server", "lib")
|
||||
if (fs.existsSync(packagedLib)) {
|
||||
return path.join(packagedLib, "*")
|
||||
}
|
||||
}
|
||||
const installLib = path.join(
|
||||
resolveProjectRoot(),
|
||||
"mactfs",
|
||||
@@ -71,6 +141,13 @@ function resolveServerClasspath() {
|
||||
* 解析 TFS native 库目录,供 SDK 加载 JNI 库。
|
||||
*/
|
||||
function resolveNativeDirectory() {
|
||||
// 打包态:JNI native 库随服务端 lib 一起落在 Resources/server/lib/native 下。
|
||||
if (app.isPackaged) {
|
||||
const packagedNative = path.join(process.resourcesPath, "server", "lib", "native")
|
||||
if (fs.existsSync(packagedNative)) {
|
||||
return packagedNative
|
||||
}
|
||||
}
|
||||
const projectRoot = resolveProjectRoot()
|
||||
const installNative = path.join(
|
||||
projectRoot,
|
||||
@@ -192,7 +269,7 @@ async function startService() {
|
||||
}
|
||||
args.push(SERVER_MAIN_CLASS)
|
||||
serverProcess = spawn(resolveJavaBin(), args, {
|
||||
cwd: path.join(resolveProjectRoot(), "mactfs"),
|
||||
cwd: app.isPackaged ? process.resourcesPath : path.join(resolveProjectRoot(), "mactfs"),
|
||||
stdio: "ignore",
|
||||
})
|
||||
serverProcess.on("exit", () => {
|
||||
@@ -290,11 +367,7 @@ function createWindow() {
|
||||
title: "MacTFS",
|
||||
// 预设背景并延迟显示,避免启动白闪;macOS 下背景透明以透出 vibrancy 毛玻璃。
|
||||
show: false,
|
||||
backgroundColor: isMac
|
||||
? "#00000000"
|
||||
: nativeTheme.shouldUseDarkColors
|
||||
? "#222226"
|
||||
: "#f9f9fa",
|
||||
backgroundColor: isMac ? "#00000000" : "#f9f9fa",
|
||||
// macOS 隐藏系统标题栏 + 窗口级毛玻璃材质;其它平台回退系统标题栏与实色背景。
|
||||
...(isMac
|
||||
? {
|
||||
@@ -323,7 +396,8 @@ function createWindow() {
|
||||
return
|
||||
}
|
||||
|
||||
mainWindow.loadFile(path.join(__dirname, "../build/client/index.html"))
|
||||
// 生产/打包态走自定义协议,保证前端的绝对资源路径(/assets/...)可被正确解析。
|
||||
mainWindow.loadURL(APP_ORIGIN)
|
||||
}
|
||||
|
||||
app.setName("MacTFS")
|
||||
@@ -334,6 +408,7 @@ app.whenReady().then(() => {
|
||||
if (process.platform === "darwin" && iconPath && app.dock) {
|
||||
app.dock.setIcon(nativeImage.createFromPath(iconPath))
|
||||
}
|
||||
registerAppProtocol()
|
||||
registerIpcHandlers()
|
||||
createWindow()
|
||||
|
||||
|
||||
@@ -1,16 +1,60 @@
|
||||
{
|
||||
"name": "mactfsui",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"description": "MacTFS - 面向 macOS 的 TFS 客户端(前端 + 内置 Java 服务)",
|
||||
"author": "mydev",
|
||||
"type": "module",
|
||||
"main": "electron/main.cjs",
|
||||
"scripts": {
|
||||
"build": "react-router build",
|
||||
"dev": "react-router dev",
|
||||
"electron": "electron electron/main.cjs",
|
||||
"electron:dev": "concurrently -k \"pnpm dev\" \"NO_PROXY=localhost,127.0.0.1 wait-on http://localhost:5173 && NODE_ENV=development electron electron/main.cjs\"",
|
||||
"start": "pnpm build && pnpm electron",
|
||||
"prepare:runtime": "node scripts/prepare-runtime.mjs",
|
||||
"dist": "pnpm build && pnpm prepare:runtime && electron-builder --mac --arm64 --x64",
|
||||
"typecheck": "react-router typegen && tsc",
|
||||
"format": "prettier --write \"**/*.{ts,tsx}\""
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.mydev.mactfs",
|
||||
"productName": "MacTFS",
|
||||
"directories": {
|
||||
"buildResources": "build-resources",
|
||||
"output": "dist-app"
|
||||
},
|
||||
"files": [
|
||||
"electron/**/*",
|
||||
"build/client/**/*",
|
||||
"package.json",
|
||||
"!node_modules/**/*"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "../mactfs/build/install/mactfs/lib",
|
||||
"to": "server/lib"
|
||||
},
|
||||
{
|
||||
"from": "runtime/jdk-x64",
|
||||
"to": "jre-x64"
|
||||
}
|
||||
],
|
||||
"mac": {
|
||||
"target": [
|
||||
{
|
||||
"target": "dmg",
|
||||
"arch": [
|
||||
"arm64",
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"category": "public.app-category.developer-tools",
|
||||
"icon": "build-resources/icon.icns",
|
||||
"identity": null
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-router/node": "7.13.1",
|
||||
"@react-router/serve": "7.13.1",
|
||||
@@ -35,6 +79,7 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"concurrently": "^9.2.1",
|
||||
"electron": "^42.2.0",
|
||||
"electron-builder": "^26.15.3",
|
||||
"playwright-core": "^1.60.0",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
|
||||
Generated
+1459
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
import { existsSync, rmSync, cpSync, mkdtempSync } from "node:fs"
|
||||
import { execFileSync } from "node:child_process"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join, resolve, dirname } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const projectRoot = resolve(here, "..", "..")
|
||||
|
||||
// 服务端 JVM 固定 x64:TFS 的 JNI 原生库只含 i386/x86_64(无 arm64),arm64 JVM 无法加载。
|
||||
// 因此 universal 包内只随附 x64 JRE,在 Apple Silicon 上经 Rosetta 2 运行服务端。
|
||||
//
|
||||
// JDK 体积大且是第三方二进制,不入库(见 .gitignore)。本地缺失时从 Azul 官方按需下载,
|
||||
// 校验 sha256 后解压到仓库根目录,再继续复制流程。
|
||||
const JDK_DIR_NAME = "zulu8.94.0.17-ca-jdk8.0.492-macosx_x64"
|
||||
const JDK_URL = `https://cdn.azul.com/zulu/bin/${JDK_DIR_NAME}.tar.gz`
|
||||
const JDK_SHA256 =
|
||||
"5114b269b88e3d89b0d6b2c28af0c96b5489f340fbaded8fa17613b2adca180c"
|
||||
|
||||
const source = join(projectRoot, JDK_DIR_NAME, "Contents", "Home")
|
||||
const dest = resolve(here, "..", "runtime", "jdk-x64")
|
||||
|
||||
function ensureJdkPresent() {
|
||||
if (existsSync(join(source, "bin", "java"))) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[prepare-runtime] 未找到本地 x64 JDK,开始从 Azul 下载…")
|
||||
console.log("[prepare-runtime] URL:", JDK_URL)
|
||||
|
||||
const workDir = mkdtempSync(join(tmpdir(), "mactfs-jdk-"))
|
||||
const archive = join(workDir, "zulu.tar.gz")
|
||||
try {
|
||||
execFileSync(
|
||||
"curl",
|
||||
["-fsSL", "--retry", "3", "--max-time", "600", "-o", archive, JDK_URL],
|
||||
{ stdio: "inherit" },
|
||||
)
|
||||
|
||||
const actualSha = execFileSync("shasum", ["-a", "256", archive])
|
||||
.toString()
|
||||
.trim()
|
||||
.split(/\s+/)[0]
|
||||
if (actualSha !== JDK_SHA256) {
|
||||
throw new Error(
|
||||
`JDK sha256 校验失败:期望 ${JDK_SHA256},实际 ${actualSha}`,
|
||||
)
|
||||
}
|
||||
console.log("[prepare-runtime] sha256 校验通过,解压到", projectRoot)
|
||||
|
||||
execFileSync("tar", ["xzf", archive, "-C", projectRoot], {
|
||||
stdio: "inherit",
|
||||
})
|
||||
} finally {
|
||||
rmSync(workDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
if (!existsSync(join(source, "bin", "java"))) {
|
||||
console.error("[prepare-runtime] 下载解压后仍未找到 x64 JDK:", source)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
ensureJdkPresent()
|
||||
|
||||
console.log("[prepare-runtime] 复制 x64 JDK ->", dest)
|
||||
rmSync(dest, { recursive: true, force: true })
|
||||
cpSync(source, dest, { recursive: true })
|
||||
|
||||
// 运行服务用不到的部分,复制后删除以缩小打包体积(仍保留 bin/java + jre + lib)。
|
||||
const drop = ["src.zip", "demo", "sample", "man", "include"]
|
||||
for (const name of drop) {
|
||||
rmSync(join(dest, name), { recursive: true, force: true })
|
||||
}
|
||||
|
||||
if (!existsSync(join(dest, "bin", "java"))) {
|
||||
console.error("[prepare-runtime] 精简后 bin/java 缺失,终止。")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log("[prepare-runtime] x64 JRE 准备完成:", dest)
|
||||
Reference in New Issue
Block a user