minecodex 2.0.4 → 2.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +40 -1
- package/README.md +9 -3
- package/apps/dashboard/DESIGN.md +53 -31
- package/apps/dashboard/app.js +55 -39
- package/apps/dashboard/assets/OFL.txt +93 -0
- package/apps/dashboard/assets/info.svg +15 -0
- package/apps/dashboard/assets/moon.svg +13 -0
- package/apps/dashboard/assets/refresh-cw.svg +16 -0
- package/apps/dashboard/assets/silkscreen.ttf +0 -0
- package/apps/dashboard/assets/sun.svg +21 -0
- package/apps/dashboard/assets/terrain.js +89 -0
- package/apps/dashboard/assets/triangle-alert.svg +15 -0
- package/apps/dashboard/favicon.svg +12 -17
- package/apps/dashboard/index.html +24 -18
- package/apps/dashboard/styles.css +147 -181
- package/assets/brand/README.md +19 -0
- package/assets/brand/minecodex-icon-dark.svg +9 -0
- package/assets/brand/minecodex-icon-light.svg +9 -0
- package/assets/brand/minecodex-icon.svg +8 -0
- package/assets/brand/references/lucide-LICENSE.txt +43 -0
- package/assets/readme/hero.svg +93 -0
- package/features/hide-upsell-banner/README.md +7 -5
- package/features/hide-upsell-banner/codex-feature.json +2 -2
- package/features/hide-upsell-banner/src/hide-upsell-banner.mjs +30 -12
- package/features/images/DESIGN.md +117 -41
- package/features/images/README.md +26 -16
- package/features/images/codex-feature.json +6 -3
- package/features/images/src/adapters/codex/image-prompt.mjs +24 -2
- package/features/images/src/adapters/codex/image-sources.mjs +19 -0
- package/features/images/src/adapters/codex/library-api.mjs +2 -0
- package/features/images/src/adapters/codex/library-viewer.mjs +26 -2
- package/features/images/src/adapters/codex/native-image-modules.mjs +211 -0
- package/features/images/src/adapters/codex/native-image-source.mjs +449 -0
- package/features/images/src/backend/local-image-source.mjs +138 -0
- package/features/images/src/frontend/cloud-image-actions.mjs +41 -0
- package/features/images/src/frontend/local-image-source.mjs +195 -0
- package/features/images/src/library-prompt-page.mjs +163 -53
- package/features/images/src/library-prompt-source.mjs +9 -3
- package/features/images/src/prompt-index.mjs +14 -2
- package/features/model-slider/DESIGN.md +2 -0
- package/features/model-slider/codex-feature.json +2 -2
- package/features/model-slider/src/frontend/install.mjs +78 -11
- package/features/notes/DESIGN.md +7 -3
- package/features/notes/codex-feature.json +2 -2
- package/features/notes/src/frontend/entry.mjs +26 -9
- package/features/session-status/DESIGN.md +4 -2
- package/features/session-status/codex-feature.json +2 -2
- package/features/session-status/src/adapters/codex/session-log.mjs +2 -1
- package/features/session-status/src/domain/session-stats.mjs +17 -9
- package/features/session-status/src/session-status.mjs +47 -11
- package/features/sidebar-focus/DESIGN.md +4 -4
- package/features/sidebar-focus/codex-feature.json +3 -2
- package/features/sidebar-focus/src/adapters/codex/renderer.mjs +1 -1
- package/features/sidebar-focus/src/sidebar-focus.mjs +9 -7
- package/features/sites/DESIGN.md +25 -3
- package/features/sites/README.md +6 -2
- package/features/sites/codex-feature.json +3 -2
- package/features/sites/src/adapters/codex/renderer.mjs +25 -4
- package/features/sites/src/backend/project-sites.mjs +18 -6
- package/features/sites/src/backend/sites-service.mjs +22 -10
- package/features/sites/src/domain/discovery-candidates.mjs +53 -0
- package/features/sites/src/frontend/entry.mjs +76 -38
- package/features/sites/src/frontend/styles.css +3 -3
- package/features/unread-inbox/DESIGN.md +24 -25
- package/features/unread-inbox/codex-feature.json +3 -2
- package/features/unread-inbox/src/adapters/codex/modules.mjs +52 -0
- package/features/unread-inbox/src/adapters/codex/renderer.mjs +90 -0
- package/features/unread-inbox/src/domain/notifications.mjs +21 -0
- package/features/unread-inbox/src/injection-source.mjs +5 -2
- package/features/unread-inbox/src/unread-inbox.mjs +212 -297
- package/features/usage-meter/codex-feature.json +2 -2
- package/package.json +11 -3
- package/packages/cli/native/MineCodex.app/Contents/Info.plist +4 -2
- package/packages/cli/native/MineCodex.app/Contents/MacOS/MineCodexServiceManager +0 -0
- package/packages/cli/native/MineCodex.app/Contents/Resources/MineCodex.icns +0 -0
- package/packages/cli/native/MineCodex.app/Contents/_CodeSignature/CodeResources +11 -0
- package/packages/cli/native/README.md +31 -0
- package/packages/cli/src/browser-control-panel.mjs +48 -0
- package/packages/cli/src/commands.mjs +3 -1
- package/packages/cli/src/control-server.mjs +6 -3
- package/packages/cli/src/platform.mjs +5 -0
- package/packages/codex-adapter/README.md +2 -1
- package/packages/codex-adapter/src/renderer/environment.mjs +24 -6
- package/packages/runtime-host/README.md +10 -5
- package/packages/runtime-host/src/codex-injection.mjs +13 -9
- package/packages/runtime-host/src/codex-runtime.mjs +26 -5
- package/packages/runtime-host/src/feature-registry.mjs +1 -0
- package/packages/ui/DESIGN.md +9 -2
- package/packages/ui/assets/arrow-up-right.svg +14 -0
- package/packages/ui/assets/lucide-LICENSE.txt +43 -0
- package/packages/ui/src/components/account-menu.mjs +74 -0
- package/packages/ui/src/components/button.mjs +2 -0
- package/packages/ui/src/components/navigation-rail.mjs +18 -10
- package/packages/ui/src/components/page.mjs +9 -2
- package/packages/ui/src/foundation/layers.mjs +1 -0
- package/packages/ui/src/install.mjs +6 -1
- package/packages/ui/src/native/account-menu.mjs +21 -0
- package/packages/ui/src/native/navigation-rail.mjs +6 -1
- package/packages/ui/src/native/page.mjs +38 -0
- package/packages/ui/src/native/tokens.mjs +4 -0
|
@@ -24,5 +24,5 @@ function target(items) {
|
|
|
24
24
|
|| items.find(b => b.key.startsWith("section:") && (selected && b.element.contains(selected) || project && b.element.contains(project.element)));
|
|
25
25
|
return section ? { section, project } : null;
|
|
26
26
|
}
|
|
27
|
-
return {branches,target};
|
|
27
|
+
return {branches,target,isBranchToggle:element=>!!element.closest("[data-app-action-sidebar-section-toggle], " + projectSelector)};
|
|
28
28
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const nativeFocus = createFocusAdapter(config.codex);
|
|
2
|
-
const
|
|
2
|
+
const COLLAPSE_GLYPH = "<svg aria-hidden=\"true\"\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"16\"\n height=\"16\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"1.575\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n>\n <path d=\"M3 5h8\" />\n <path d=\"M3 12h8\" />\n <path d=\"M3 19h8\" />\n <path d=\"m15 5 3 3 3-3\" />\n <path d=\"m15 19 3-3 3 3\" />\n</svg>\n";
|
|
3
|
+
const EXPAND_GLYPH = "<svg aria-hidden=\"true\"\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"16\"\n height=\"16\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"1.575\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n>\n <path d=\"M3 5h8\" />\n <path d=\"M3 12h8\" />\n <path d=\"M3 19h8\" />\n <path d=\"m15 8 3-3 3 3\" />\n <path d=\"m15 16 3 3 3-3\" />\n</svg>\n";
|
|
3
4
|
|
|
4
5
|
|
|
5
6
|
const states = new Map();
|
|
@@ -22,7 +23,7 @@ function reconcile() {
|
|
|
22
23
|
button.setAttribute("data-minecodex-focus", "");
|
|
23
24
|
// 复用原生内层容器,由其负责图标在热区内居中。
|
|
24
25
|
const inner = bell.firstElementChild.cloneNode(false);
|
|
25
|
-
inner.innerHTML =
|
|
26
|
+
inner.innerHTML = COLLAPSE_GLYPH;
|
|
26
27
|
button.replaceChildren(inner);
|
|
27
28
|
button.removeAttribute("title");
|
|
28
29
|
button.removeAttribute("aria-describedby");
|
|
@@ -38,16 +39,17 @@ function reconcile() {
|
|
|
38
39
|
}
|
|
39
40
|
anchor.after(button);
|
|
40
41
|
}
|
|
41
|
-
const label = active ? text("
|
|
42
|
+
const label = active ? text("Restore layout", "恢复布局") : text("Focus current task", "聚焦当前任务");
|
|
42
43
|
if (button.getAttribute("aria-label") !== label) button.setAttribute("aria-label", label);
|
|
43
44
|
button.setAttribute("data-minecodex-tooltip", label);
|
|
44
45
|
const pressed = String(active);
|
|
45
46
|
if (button.getAttribute("aria-pressed") !== pressed) {
|
|
46
47
|
button.setAttribute("aria-pressed", pressed);
|
|
48
|
+
button.firstElementChild.innerHTML = active ? EXPAND_GLYPH : COLLAPSE_GLYPH;
|
|
47
49
|
}
|
|
48
|
-
//
|
|
49
|
-
button.setAttribute("data-color",
|
|
50
|
-
button.setAttribute("data-variant",
|
|
50
|
+
// 图标表达下一步操作;保持中性按钮,不显示持续模式的选中底色。
|
|
51
|
+
button.setAttribute("data-color", "secondary");
|
|
52
|
+
button.setAttribute("data-variant", "ghost");
|
|
51
53
|
const items = nativeFocus.branches(), path = nativeFocus.target(items);
|
|
52
54
|
const activityActive = bell.getAttribute("aria-pressed") === "true";
|
|
53
55
|
button.disabled = activityActive || (!active && !restoring && !path);
|
|
@@ -74,7 +76,7 @@ function schedule() {
|
|
|
74
76
|
}
|
|
75
77
|
document.addEventListener("click", event => {
|
|
76
78
|
if (!active || !event.isTrusted) return;
|
|
77
|
-
if (event.target
|
|
79
|
+
if (nativeFocus.isBranchToggle(event.target)) {
|
|
78
80
|
active = false; states.clear(); schedule();
|
|
79
81
|
}
|
|
80
82
|
}, { capture:true, signal });
|
package/features/sites/DESIGN.md
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
## 用户路径
|
|
8
8
|
|
|
9
|
-
Sites 标题工具区增加次要操作 Add site,保留官方
|
|
9
|
+
Sites 标题工具区增加次要操作 Add site,保留官方 New。添加弹窗默认展示 Codex 本地项目中的网页入口候选(含未启动网站),不显示重复的项目分组标题,项目名称仅在候选次级文本呈现;候选使用 checkbox 多选,点击整行切换勾选;已收录项不再出现在候选列表中。两个模式底部统一提供 Cancel / Add,项目模式未选择时禁用 Add,点击后才批量收录;取消不保存选择。候选不自动收录;扫描不启动服务。打开已收录的未启动站点时,使用支持的启动方式按需启动。弹窗使用「Project / Custom」分段按钮切换;切换保留已填写的网址;项目模式自动扫描并直接展示候选,不显示搜索框、说明行或刷新按钮。「自定义网址」只提供网址和名称,默认作为外部网址收录,在无项目聊天中打开;不显示开发来源及说明。网址与名称均必填;名称不能仅含空白且最多160字符,网址须为不含嵌入凭证的有效 HTTP(S) 地址。不符合时禁用 Add,输入变化后实时更新。项目关联自动来自扫描,不在编辑弹窗暴露。
|
|
10
10
|
|
|
11
|
-
通过 MineCodex
|
|
11
|
+
通过 MineCodex 添加的条目直接追加在 Space → Sites 的原生列表/网格中,保留官方条目,共用原生搜索与列表/网格切换,不提供额外筛选 tabs。标题下只显示主机地址与端口;访问列用图标 + Local address / External site,沿用原生 16px 图标、主文字色、text-sm 与 gap-1.5。Local 和 External 同时展示,访问列区分地址类型。操作列复用原生外部箭头,在默认浏览器打开;Project 条目菜单只提供移除,Custom URL 可编辑网址与名称,沿用添加校验。移除只移除入口。
|
|
12
12
|
|
|
13
13
|
## 原生 token 与组件
|
|
14
14
|
|
|
@@ -17,7 +17,7 @@ Sites 标题工具区增加次要操作 Add site,保留官方 Create。添加
|
|
|
17
17
|
- 按钮:复用原生 `_Button` 与 `_ButtonInner` 类以及 data-color / data-variant / data-size 状态接口。Add site 为 secondary outline;确认沿用 primary solid。
|
|
18
18
|
- 弹窗:选择通用 Dialog 的 opaque 分支(Sites Share),最大宽560px,不加独立操作区分隔线;当前通过 `config.ui` 接入。通用容器、遮罩、标题、Close及焦点契约遵循根规范。
|
|
19
19
|
- 分段按钮:使用原生 segmented-control 的统一底板、等宽选项及选中块,复用 gap/gutter/background/font-weight/thumb-background/thumb-shadow/option-highlight token;控件尺寸为 control-size-md,支持方向键切换与选中状态语义。
|
|
20
|
-
- 图标:从原生 Sites、
|
|
20
|
+
- 图标:从原生 Space 下的 Sites、New、行菜单节点克隆 SVG;不重画。扫描结果和添加后的条目异步获取真实网页截图,本地缓存;完成后替换占位。没有截图时保留原生 Sites 图标,不阻塞选择和添加。
|
|
21
21
|
|
|
22
22
|
## 打开与布局
|
|
23
23
|
|
|
@@ -58,3 +58,25 @@ Custom 地址分类:localhost/回环、IPv4私有网段及链路本地、IPv6
|
|
|
58
58
|
|
|
59
59
|
新版 Sites 位于 `/space/sites`;注入与离开页面的布局判断使用该路由,移除旧 `/sites` 判断。
|
|
60
60
|
标题区通过 New 的语义定位创建入口;按钮容器复用原生 lg secondary Button,不再假设 New 提供 data-variant。
|
|
61
|
+
|
|
62
|
+
2026-10-02 隔离原生页面验证:新版已移除 Site ownership 筛选组;注入不依赖该组,也不隐藏官方条目。仅在原生列表为空且存在匹配的本地站点时隐藏原生空状态,保留头部与工具区;搜索无匹配或卸载时恢复。空状态定位排除本插件节点。
|
|
63
|
+
|
|
64
|
+
添加候选行使用 flex 横排:96×60 预览、可收缩的标题/项目名、16px checkbox,间距12px;表单 label 的块布局不应用于候选行。New 的 SVG 带桌面隐藏类,克隆为 Add site 图标时移除该显示限制。
|
|
65
|
+
|
|
66
|
+
## 添加候选筛选
|
|
67
|
+
|
|
68
|
+
Project 模式保持单一完整列表,按标题首字母排序(忽略大小写、数字自然顺序),同名按项目名与路径稳定排序;不设推荐、数量上限、筛选 tabs 或版本展开。
|
|
69
|
+
|
|
70
|
+
同一项目、相同归一目录的迭代仅展示一个入口:识别目录段或后缀中的 v/ver/version/rev/revision 数字版本,以及 final/latest/copy/backup/old 副本标记,也识别 name-1.2.0 形式的发布目录。明确版本号最高者优先;无版本号或版本相同时按入口文件修改时间选取。修改时间只用于内部选择,不作为运行或访问时间。不同目录和项目不因标题相同而合并。此规则作用于当前已支持的站点入口,不额外枚举任意 HTML 文件。
|
|
71
|
+
|
|
72
|
+
排除明确自测/证据目录(test/tests、__tests__、fixtures、test-results、playwright-report、coverage、agent-tests/checks/notes/reports、self-test)及 docs/research、internal-notes、implementation-notes;明确自测报告/实现说明标题同样排除。带 SKILL.md 的技能目录下 demo/example/examples/preview 属于技能说明示例,同样排除;普通项目的 docs、demo、prototype 保留。历史记录仅补充当前磁盘入口或在线服务,失效历史记录从发现缓存清除;不删除用户文件或已保存站点。
|
|
73
|
+
|
|
74
|
+
已添加的 entryId、相同 URL 或同一迭代组不再呈现为候选;后端仍保留完整有效入口供已保存站点启动与解析,不自动迁移用户之前保存的版本。全部已添加时显示“暂无可添加的项目网页”。批量添加部分成功时,成功项从候选中移除,失败项保留选择。
|
|
75
|
+
|
|
76
|
+
## 浏览器快捷添加
|
|
77
|
+
|
|
78
|
+
Codex 26.930.21537 隔离窗口验证:浏览器 panel 的 More(Browser options)菜单在 Downloads 下方插入 Add to Sites,复用原生菜单行样式;保持 History 等原生动作及 Escape 行为。方向键可经过注入项,Enter/Space 可激活,卸载移除入口。
|
|
79
|
+
|
|
80
|
+
通过菜单所属浏览器的原生状态读取实时 URL、标题及本地 cwd,不读取地址栏草稿,也不使用其他选中标签的缓存。仅支持无嵌入凭证的 HTTP(S) 页面。点击后预填添加弹窗,沿用 Project / Custom 分段选择与网址、名称校验。已确认本地项目归属的本地地址默认 Project;其余默认 Custom。有本地项目上下文时可切换 Project,无归属时禁用该项。远程浏览器路径不映射为本机项目。
|
|
81
|
+
|
|
82
|
+
保存 Project 使用已确认项目身份,Custom 使用独立网址身份;复用既有重复检测。保存或取消后仍留在浏览器,不跳转 Sites,不发送聊天消息。原生 browserState、菜单动作及关闭协议仅由 Sites Adapter 解释,后端负责校验 URL 和本机项目目录归属。
|
package/features/sites/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# Local & Self-hosted Sites
|
|
2
2
|
|
|
3
|
-
Extends the native Sites page. **Add site** discovers website entries from local Codex project files, including stopped sites, and supplements them with running HTML services. Select candidates with checkboxes and confirm with Add, or use Custom URL to enter an HTTP/HTTPS URL and name. Both modes provide Cancel / Add; Add stays disabled until the selection or required fields are valid.
|
|
3
|
+
Extends the native Sites page. **Add site** discovers website entries from local Codex project files, including stopped sites, and supplements them with running HTML services. Candidates form one alphabetical list: recognized iterations show only the latest, already-added sites are omitted, and clear agent test/report artifacts and invalid discovery history are excluded. Select candidates with checkboxes and confirm with Add, or use Custom URL to enter an HTTP/HTTPS URL and name. Both modes provide Cancel / Add; Add stays disabled until the selection or required fields are valid.
|
|
4
|
+
|
|
5
|
+
**Browser shortcut:** open the browser panel’s More menu and choose **Add to Sites** below Downloads. URL and name are prefilled from that browser tab. Choose Project (when local project context is available) or Custom and confirm; the browser stays open. Local addresses with a confirmed project default to Project; other pages default to Custom. Only HTTP(S) URLs without embedded credentials are accepted.
|
|
4
6
|
|
|
5
7
|
- Project: opens a native new draft in that project.
|
|
6
8
|
- Projectless chat: reopens the original conversation.
|
|
@@ -22,6 +24,8 @@ Discovery never starts services. Opening a saved stopped site can start a suppor
|
|
|
22
24
|
|
|
23
25
|
## Native UI boundary
|
|
24
26
|
|
|
25
|
-
The renderer owns `data-mcx-sites` elements
|
|
27
|
+
The renderer owns `data-mcx-sites` elements. Saved local sites join the native Space → Sites list/grid and search without additional filter tabs. Native empty states are suppressed only while matching local results are displayed and restored on cleanup. It uses native Sites layout classes, current semantic theme tokens, and the existing Prompts dialog/menu patterns. The route-state and resize adapters depend on native renderer internals and must be verified against app updates. Icon sources: native Sites controls, existing Prompts resources, and the Lucide monitor icon; Lucide license is included in `vendor/`.
|
|
26
28
|
|
|
27
29
|
弹窗与浮层统一调用 UI 组件库提供的 `config.ui`(由 Runtime Host 注入),共享主题材质、标题、Close 和表单操作区;插件只维护内容及交互。
|
|
30
|
+
|
|
31
|
+
Iteration selection uses explicit numeric version directory names/suffixes (v/ver/version/rev/revision), then entry-file modification time for equal or unnumbered copies (final/latest/copy/backup/old). Titles alone never merge independent sites. This applies to discovered entries, not arbitrary HTML files. Semver release directories such as name-1.2.0 are also recognized. Demo/example/preview folders owned by a SKILL.md are treated as agent skill documentation; ordinary project documentation sites, demos and prototypes remain eligible. Saved entries keep their existing target; discovery filtering does not rewrite the library. Invalid history is pruned only when no current file entry or live service supports it.
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
"zh-CN": "本地与自托管站点"
|
|
8
8
|
},
|
|
9
9
|
"description": {
|
|
10
|
-
"en": "
|
|
11
|
-
"zh-CN": "
|
|
10
|
+
"en": "Find project web services and open your saved sites.",
|
|
11
|
+
"zh-CN": "发现项目网页服务,收藏并打开常用站点。"
|
|
12
12
|
},
|
|
13
13
|
"entry": {
|
|
14
14
|
"kind": "page-script",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"hostActions": [
|
|
23
23
|
"sites"
|
|
24
24
|
],
|
|
25
|
+
"icon": {"viewBox": "0 0 24 24", "markup": "<circle cx=\"12\" cy=\"12\" r=\"10\" />\n <path d=\"M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20\" />\n <path d=\"M2 12h20\" />"},
|
|
25
26
|
"placement": {
|
|
26
27
|
"order": 90
|
|
27
28
|
}
|
|
@@ -31,7 +31,26 @@ function pageInfo() {
|
|
|
31
31
|
const shared = native ? !!native.shared : [...page.querySelectorAll("button[aria-pressed=true]")].some(b => /Shared with you|与你共享|與你共用/.test(b.textContent));
|
|
32
32
|
const create = [...page.firstElementChild.querySelectorAll("button")].find(b => /^(New|新建|新增)$/.test(b.textContent.trim()) && !b.hasAttribute(owned));
|
|
33
33
|
codex.report("sites-page",true);
|
|
34
|
-
return { page, input, content, list, grid, shared, create };
|
|
34
|
+
return { page, input, content, list, grid, shared, create, empty: list ? null : [...content.children].findLast(e => !e.hasAttribute(owned)) };
|
|
35
|
+
}
|
|
36
|
+
function browserMenu() {
|
|
37
|
+
for (const menu of document.querySelectorAll('[role="menu"]')) {
|
|
38
|
+
if (!visible(menu)) continue;
|
|
39
|
+
const actions = nativeProps(menu, p => typeof p.onOpenDownloads === "function" && typeof p.onOpenChange === "function");
|
|
40
|
+
const page = nativeProps(menu, p => p.browserState && p.browserTabId);
|
|
41
|
+
if (!actions || !page || page.isVisible === false) continue;
|
|
42
|
+
const downloads = [...menu.querySelectorAll('[role="menuitem"]')].find(e => /^(Downloads|下载|下載)$/.test(e.textContent.trim()));
|
|
43
|
+
if (!downloads) continue;
|
|
44
|
+
const host = nativeProps(menu, p => p.browserTabId === page.browserTabId && "browserHostDisplayName" in p);
|
|
45
|
+
let url;
|
|
46
|
+
try { url = new URL(page.browserState.url); } catch { continue; }
|
|
47
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) continue;
|
|
48
|
+
return { menu, downloads, tabId: page.browserTabId,
|
|
49
|
+
page: { url: url.href, title: page.browserState.title || url.hostname,
|
|
50
|
+
cwd: host?.browserHostDisplayName === "Local" ? page.cwd : null },
|
|
51
|
+
close: () => actions.onOpenChange(false) };
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
35
54
|
}
|
|
36
55
|
function fullViewControl(enter) {
|
|
37
56
|
const pattern = enter ? /^(Enter full view|进入完整视图|进入全屏视图|進入完整檢視|進入全螢幕檢視)$/ : /^(Exit full view|退出完整视图|退出全屏视图|退出完整檢視|退出全螢幕檢視)$/;
|
|
@@ -67,10 +86,12 @@ function templates(page) {
|
|
|
67
86
|
const create = page.create;
|
|
68
87
|
if (create) {
|
|
69
88
|
const template = page.page.querySelector('button[data-size="lg"][data-color="secondary"]');
|
|
70
|
-
nativeButton = template.className; nativeInner = template.firstElementChild
|
|
89
|
+
if (template) { nativeButton = template.className; nativeInner = template.firstElementChild?.className || ""; }
|
|
71
90
|
nativePlus = create.querySelector("svg")?.cloneNode(true);
|
|
91
|
+
// New 的图标在桌面端隐藏,克隆后只保留图标自身样式。
|
|
92
|
+
nativePlus?.classList.remove("hidden", "browser:max-md:block");
|
|
72
93
|
}
|
|
73
|
-
nativeSiteIcon ||= document.
|
|
94
|
+
nativeSiteIcon ||= [...document.querySelectorAll('button, a')].find(e => /^(Sites|站点|網站)$/.test(e.textContent.trim()) && !e.closest(`[${owned}]`))?.querySelector("svg")?.cloneNode(true);
|
|
74
95
|
const row = page.list?.querySelector("[data-appgen-row], article");
|
|
75
96
|
nativeExternal ||= row?.querySelector('button[aria-label*="new tab"]')?.cloneNode(true);
|
|
76
97
|
nativeActionsClass ||= row?.lastElementChild?.className;
|
|
@@ -79,5 +100,5 @@ function templates(page) {
|
|
|
79
100
|
return {nativeButton,nativeInner,nativePlus,nativeSiteIcon,nativeMore,nativeExternal,nativeGlobe,nativeActionsClass};
|
|
80
101
|
}
|
|
81
102
|
|
|
82
|
-
return {templates,pageInfo,fullViewControl,open,path:()=>router()?.state?.location?.pathname};
|
|
103
|
+
return {templates,pageInfo,browserMenu,fullViewControl,open,path:()=>router()?.state?.location?.pathname};
|
|
83
104
|
}
|
|
@@ -1,14 +1,24 @@
|
|
|
1
|
-
import { readFile, readdir, realpath } from "node:fs/promises";
|
|
1
|
+
import { readFile, readdir, realpath, stat } from "node:fs/promises";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { isAuxiliarySite } from "../domain/discovery-candidates.mjs";
|
|
4
5
|
|
|
5
6
|
const ignored = new Set(["node_modules", "dist", "build", "out", "vendor", "target", "coverage"]);
|
|
6
7
|
export const entryKey = (projectId, directory) => createHash("sha256").update(`${projectId}\0${directory}`).digest("hex");
|
|
8
|
+
export async function isSkillExample(directory, root) {
|
|
9
|
+
let current = directory;
|
|
10
|
+
while (current && current !== root && current !== path.dirname(current)) {
|
|
11
|
+
if (/^(?:demo|examples?|preview)$/i.test(path.basename(current))
|
|
12
|
+
&& await stat(path.join(path.dirname(current), "SKILL.md")).then(s => s.isFile(), () => false)) return true;
|
|
13
|
+
current = path.dirname(current);
|
|
14
|
+
}
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
7
17
|
export async function findProjectSites(projects) {
|
|
8
18
|
const found = [], visited = new Set();
|
|
9
19
|
let remaining = 4000;
|
|
10
|
-
async function walk(directory, project, depth) {
|
|
11
|
-
if (--remaining < 0 || depth > 4) return;
|
|
20
|
+
async function walk(directory, project, root, depth) {
|
|
21
|
+
if (--remaining < 0 || depth > 4 || isAuxiliarySite(path.relative(root, directory))) return;
|
|
12
22
|
const canonical = await realpath(directory).catch(() => null);
|
|
13
23
|
if (!canonical || visited.has(canonical)) return; visited.add(canonical);
|
|
14
24
|
let entries; try { entries = await readdir(canonical, { withFileTypes: true }); } catch { return; }
|
|
@@ -23,12 +33,14 @@ export async function findProjectSites(projects) {
|
|
|
23
33
|
if (selected || staticEntry) {
|
|
24
34
|
let title = pkg?.name || path.basename(canonical);
|
|
25
35
|
if (staticEntry) { const html = await readFile(path.join(canonical, "index.html"), "utf8").catch(() => ""); title = html.match(/<title[^>]*>([^<]*)<\/title>/i)?.[1]?.trim() || title; }
|
|
26
|
-
|
|
36
|
+
if (isAuxiliarySite(path.relative(root, canonical), title)) return;
|
|
37
|
+
const modifiedAt = (await stat(path.join(canonical, staticEntry ? "index.html" : "package.json"))).mtimeMs;
|
|
38
|
+
found.push({ modifiedAt, entryId: entryKey(project.id, canonical), directory: canonical, projectId: project.id, projectName: project.name, title, framework: selected?.framework || "static", script: selected?.script || null, command: selected?.command || null, url: null, status: "stopped" });
|
|
27
39
|
}
|
|
28
|
-
for (const entry of entries) if (entry.isDirectory() && !entry.name.startsWith(".") && !ignored.has(entry.name)) await walk(path.join(canonical, entry.name), project, depth + 1);
|
|
40
|
+
for (const entry of entries) if (entry.isDirectory() && !entry.name.startsWith(".") && !ignored.has(entry.name) && !(entries.some(e => e.name === "SKILL.md") && /^(?:demo|examples?|preview)$/i.test(entry.name))) await walk(path.join(canonical, entry.name), project, root, depth + 1);
|
|
29
41
|
}
|
|
30
42
|
// 深层项目先归属,避免被父项目重复收录。
|
|
31
43
|
const roots = projects.flatMap(p => p.rootPaths.map(root => ({ root, project: p }))).sort((a,b) => b.root.length - a.root.length);
|
|
32
|
-
for (const { root, project } of roots) await walk(root, project, 0);
|
|
44
|
+
for (const { root, project } of roots) await walk(root, project, root, 0);
|
|
33
45
|
return found;
|
|
34
46
|
}
|
|
@@ -6,7 +6,8 @@ import os from "node:os";
|
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { CodexCatalog } from "../adapters/codex/catalog.mjs";
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import { isAuxiliarySite, selectDiscoveryCandidates } from "../domain/discovery-candidates.mjs";
|
|
10
|
+
import { findProjectSites, entryKey, isSkillExample } from "./project-sites.mjs";
|
|
10
11
|
import { SiteLauncher } from "./site-launcher.mjs";
|
|
11
12
|
import { SitePreviews } from "./sites-preview.mjs";
|
|
12
13
|
|
|
@@ -45,17 +46,19 @@ export class SitesService {
|
|
|
45
46
|
const attributed = await Promise.all(listeners.map(async e => {
|
|
46
47
|
const cwd = e.cwd ? await realpath(e.cwd).catch(() => e.cwd) : null;
|
|
47
48
|
const project = projectForDirectory(canonical, cwd) || (process.platform === "win32" ? canonical.filter(p => p.rootPaths.some(r => String(e.command).toLowerCase().includes((r + path.sep).toLowerCase()))).sort((a,b) => b.rootPaths[0].length - a.rootPaths[0].length)[0] : null);
|
|
48
|
-
|
|
49
|
+
const root = project?.rootPaths.find(r => projectForDirectory([{ rootPaths: [r] }], cwd)) || project?.rootPaths[0];
|
|
50
|
+
const auxiliary = root && (isAuxiliarySite(path.relative(root, cwd || root)) || cwd && await isSkillExample(cwd, root));
|
|
51
|
+
return { ...e, cwd, project, auxiliary };
|
|
49
52
|
}));
|
|
50
53
|
// 模拟器桥接首页也会执行 simctl;须在 HTTP 探测前排除,避免安装授权弹窗。
|
|
51
54
|
// 无法读取完整命令时不主动探测,仍允许用户手动添加网址。
|
|
52
|
-
const pending = attributed.filter(e => e.project && e.command && !/(?:^|[\s/\\])(?:serve-sim|swiftui-preview-browser)(?:[.\s/\\]|$)/i.test(e.command));
|
|
55
|
+
const pending = attributed.filter(e => e.project && e.command && !e.auxiliary && !/(?:^|[\s/\\])(?:serve-sim|swiftui-preview-browser)(?:[.\s/\\]|$)/i.test(e.command));
|
|
53
56
|
await Promise.all(Array.from({ length: Math.min(6, pending.length) }, async () => {
|
|
54
57
|
while (pending.length && Date.now() < deadline) {
|
|
55
58
|
const entry = pending.shift();
|
|
56
59
|
if ([...this.launcher.running.values()].some(r => r.url === entry.url)) continue;
|
|
57
60
|
if (seen.has(entry.url)) continue; seen.add(entry.url);
|
|
58
|
-
try { const page = await this.probe(entry.url, { timeoutMs: Math.min(1400, Math.max(1, deadline - Date.now())) }); if (page) candidates.push({ title: page.title, url: entry.url, projectId: entry.project.id, projectName: entry.project.name, directory: entry.cwd, status: "running" }); } catch { /* 未就绪或非 HTTP 服务不列为网页。 */ }
|
|
61
|
+
try { const page = await this.probe(entry.url, { timeoutMs: Math.min(1400, Math.max(1, deadline - Date.now())) }); if (page && !isAuxiliarySite("", page.title)) candidates.push({ title: page.title, url: entry.url, projectId: entry.project.id, projectName: entry.project.name, directory: entry.cwd, status: "running" }); } catch { /* 未就绪或非 HTTP 服务不列为网页。 */ }
|
|
59
62
|
}
|
|
60
63
|
}));
|
|
61
64
|
const historyFile = path.join(path.dirname(this.file), "project-history.json");
|
|
@@ -64,24 +67,33 @@ export class SitesService {
|
|
|
64
67
|
for (const candidate of candidates) {
|
|
65
68
|
const definition = definitions.filter(d => d.projectId === candidate.projectId && candidate.directory && projectForDirectory([{ rootPaths: [d.directory] }], candidate.directory)).sort((a,b) => b.directory.length - a.directory.length)[0];
|
|
66
69
|
candidate.entryId = definition?.entryId || entryKey(candidate.projectId, candidate.directory || candidate.url);
|
|
67
|
-
if (definition) { Object.assign(candidate, { framework: definition.framework, script: definition.script, command: definition.command }); definitions.splice(definitions.indexOf(definition), 1); }
|
|
70
|
+
if (definition) { Object.assign(candidate, { directory: definition.directory, modifiedAt: definition.modifiedAt, framework: definition.framework, script: definition.script, command: definition.command }); definitions.splice(definitions.indexOf(definition), 1); }
|
|
68
71
|
history[candidate.entryId] = { ...history[candidate.entryId], projectId:candidate.projectId,projectName:candidate.projectName,directory:candidate.directory, lastSeenAt: Date.now(), url: candidate.url, title: candidate.title };
|
|
69
72
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
+
// 历史只补充当前入口的运行信息,不再独立生成离线候选。
|
|
74
|
+
const validIds = new Set([...candidates, ...definitions].map(c => c.entryId));
|
|
75
|
+
for (const id of Object.keys(history)) if (!validIds.has(id)) delete history[id];
|
|
73
76
|
for (const definition of definitions) {
|
|
74
77
|
const running = this.launcher.running.get(definition.entryId);
|
|
75
|
-
candidates.push({ ...
|
|
78
|
+
candidates.push({ ...history[definition.entryId], ...definition, url: running?.url || history[definition.entryId]?.url || null, status: running ? "running" : "stopped" });
|
|
76
79
|
}
|
|
77
80
|
for (const candidate of candidates) Object.assign(candidate, { lastSeenAt: history[candidate.entryId]?.lastSeenAt, lastStartedAt: history[candidate.entryId]?.lastStartedAt });
|
|
78
81
|
await mkdir(path.dirname(this.file), { recursive: true, mode: 0o700 });
|
|
79
82
|
await writeFile(historyFile, JSON.stringify(history), { mode: 0o600 });
|
|
80
83
|
this.entries = new Map(candidates.map(c => [c.entryId, c])); this.scannedAt = Date.now();
|
|
81
84
|
this.discoveredUrls = new Set(candidates.filter(c => c.status === "running").map(c => c.url));
|
|
82
|
-
return { candidates: candidates
|
|
85
|
+
return { candidates: selectDiscoveryCandidates(candidates, await this.read()), scope: "local-projects" };
|
|
83
86
|
}
|
|
84
87
|
async action(payload = {}) {
|
|
88
|
+
if (payload.operation === "prepare-browser-site") {
|
|
89
|
+
const site = normalizeSite({ url: payload.url, title: payload.title, origin: { kind: "external" } });
|
|
90
|
+
const { projects } = await this.catalog();
|
|
91
|
+
const cwd = payload.cwd ? await realpath(payload.cwd).catch(() => null) : null;
|
|
92
|
+
const canonical = await Promise.all(projects.map(async p => ({ ...p, rootPaths: await Promise.all(p.rootPaths.map(r => realpath(r).catch(() => r))) })));
|
|
93
|
+
const project = cwd ? projectForDirectory(canonical, cwd) : null;
|
|
94
|
+
return { site, project: project ? { id: project.id, name: project.name } : null,
|
|
95
|
+
preferProject: !!project && isLocalAddress(new URL(site.url).hostname) };
|
|
96
|
+
}
|
|
85
97
|
if (payload.operation === "start") {
|
|
86
98
|
const site = (await this.read()).find(s => s.id === payload.id);
|
|
87
99
|
if (!site || site.origin.kind !== "project") throw new Error("Project site is unavailable.");
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const auxiliary = /^(?:__tests__|__fixtures__|tests?|fixtures?|test-results|playwright-report|coverage|agent[-_]?(?:tests?|checks?|notes|reports)|self[-_]?tests?|(?:.*[-_])?(?:smoke|self)[-_]tests?)$/i;
|
|
2
|
+
export function isAuxiliarySite(relativePath, title = "") {
|
|
3
|
+
const parts = relativePath.split(/[\\/]/);
|
|
4
|
+
return parts.some(part => auxiliary.test(part))
|
|
5
|
+
|| /(?:^|\/)(?:docs\/research|internal-notes|implementation-notes)(?:\/|$)/i.test(parts.join("/"))
|
|
6
|
+
|| /(?:^|\b)(?:(?:agent|自动化|代理)\s*[-::]?\s*)?(?:self[- ]?test|smoke test|test report|implementation notes|自测报告|测试报告|实现说明)(?:\b|\s|[-::]|$)/i.test(title);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function candidateFamily(candidate) {
|
|
10
|
+
const directory = candidate.directory;
|
|
11
|
+
if (!directory) return { key: `${candidate.projectId}\0${candidate.entryId || candidate.url}`, version: [] };
|
|
12
|
+
const parts = directory.split(/[\\/]/), version = [];
|
|
13
|
+
const normalized = parts.map(part => {
|
|
14
|
+
const match = part.match(/^(?:(.*?)[-_ .])?(?:v|ver|version|rev|revision)[-_ .]?(\d+(?:[._]\d+)*)$/i);
|
|
15
|
+
const release = part.match(/^(.*?)[-_ ](\d+\.\d+(?:\.\d+)*)$/);
|
|
16
|
+
if (release) { version.push(...release[2].split(".").map(Number)); return release[1]; }
|
|
17
|
+
if (match) { version.push(...match[2].split(/[._]/).map(Number)); return (match[1] || "").replace(/[-_ .]+$/, ""); }
|
|
18
|
+
if (/^(?:final|latest|copy|backup|old)$/i.test(part)) return "";
|
|
19
|
+
return part.replace(/[-_ ](?:final|latest|copy|backup|old)$/i, "");
|
|
20
|
+
}).filter(Boolean).join("/");
|
|
21
|
+
return { key: `${candidate.projectId}\0${normalized}`, version };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function newer(a, b) {
|
|
25
|
+
const av = candidateFamily(a).version, bv = candidateFamily(b).version;
|
|
26
|
+
// 明确版本号不受复制文件或格式化导致的时间变化影响。
|
|
27
|
+
if (av.length || bv.length) {
|
|
28
|
+
for (let i = 0; i < Math.max(av.length, bv.length); i++) {
|
|
29
|
+
const difference = (av[i] || 0) - (bv[i] || 0);
|
|
30
|
+
if (difference) return difference;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return (a.modifiedAt || 0) - (b.modifiedAt || 0)
|
|
34
|
+
|| Number(a.status === "running") - Number(b.status === "running")
|
|
35
|
+
|| (a.directory || a.url || "").localeCompare(b.directory || b.url || "", "en", { numeric: true });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function selectDiscoveryCandidates(candidates, saved) {
|
|
39
|
+
const families = new Map();
|
|
40
|
+
for (const candidate of candidates) {
|
|
41
|
+
const key = candidateFamily(candidate).key;
|
|
42
|
+
const previous = families.get(key);
|
|
43
|
+
if (!previous || newer(candidate, previous) > 0) families.set(key, candidate);
|
|
44
|
+
}
|
|
45
|
+
const savedFamilies = new Set(saved.filter(s => s.origin?.kind === "project" && s.directory)
|
|
46
|
+
.map(s => candidateFamily({ ...s, projectId: s.origin.projectId }).key));
|
|
47
|
+
return [...families].filter(([key, candidate]) => !savedFamilies.has(key) && !saved.some(s =>
|
|
48
|
+
s.entryId === candidate.entryId || candidate.url && s.url === candidate.url
|
|
49
|
+
)).map(([, candidate]) => candidate).sort((a, b) =>
|
|
50
|
+
a.title.localeCompare(b.title, "en", { sensitivity: "base", numeric: true })
|
|
51
|
+
|| a.projectName.localeCompare(b.projectName, "en", { sensitivity: "base", numeric: true })
|
|
52
|
+
|| (a.directory || a.url).localeCompare(b.directory || b.url));
|
|
53
|
+
}
|
|
@@ -7,7 +7,7 @@ const host = payload => config.requestHostAction("sites", payload);
|
|
|
7
7
|
const owned = "data-mcx-sites";
|
|
8
8
|
let disposed = false, state = { sites: [], projects: [], threads: [] }, addButton, rowsRoot, fallback, dialog, menu;
|
|
9
9
|
let rowSignature = "", nativeButton, nativeInner, nativePlus, nativeSiteIcon, nativeMore, timer, revision = 0, opening = false;
|
|
10
|
-
let
|
|
10
|
+
let nativeExternal, nativeGlobe, nativeActionsClass;
|
|
11
11
|
let currentPage, refreshAt = 0, pendingLayout = null;
|
|
12
12
|
const style = document.createElement("style");
|
|
13
13
|
style.setAttribute(owned, "style");
|
|
@@ -147,31 +147,73 @@ function row(site, grid) {
|
|
|
147
147
|
}
|
|
148
148
|
return r;
|
|
149
149
|
}
|
|
150
|
-
function
|
|
151
|
-
const
|
|
152
|
-
if (!
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
150
|
+
function updateBrowserMenu() {
|
|
151
|
+
const context = nativeSites.browserMenu();
|
|
152
|
+
if (!context || context.menu.querySelector(`[${owned}=browser-add]`)) return;
|
|
153
|
+
const item = context.downloads.cloneNode(true);
|
|
154
|
+
item.setAttribute(owned, "browser-add"); item.removeAttribute("id"); item.removeAttribute("data-radix-collection-item");
|
|
155
|
+
item.removeAttribute("data-highlighted"); item.tabIndex = -1;
|
|
156
|
+
item.querySelector('[data-menu-row-content]')?.replaceChildren(document.createTextNode(text("Add to Sites", "添加到站点")));
|
|
157
|
+
item.onclick = async event => {
|
|
158
|
+
event.preventDefault(); event.stopPropagation();
|
|
159
|
+
// 点击时重新读取菜单所属标签,避免使用菜单打开前的网址。
|
|
160
|
+
const current = nativeSites.browserMenu();
|
|
161
|
+
if (!current || current.menu !== context.menu) return;
|
|
162
|
+
current.close();
|
|
163
|
+
try {
|
|
164
|
+
const prepared = await host({ operation: "prepare-browser-site", ...current.page });
|
|
165
|
+
if (!disposed) showBrowserAdd(prepared);
|
|
166
|
+
} catch (error) { notice(error.message); }
|
|
167
|
+
};
|
|
168
|
+
item.onpointermove = () => item.focus({ preventScroll: true });
|
|
169
|
+
context.downloads.after(item);
|
|
170
|
+
}
|
|
171
|
+
function browserMenuKey(event) {
|
|
172
|
+
const root = event.target.closest?.('[role="menu"]');
|
|
173
|
+
const item = root?.querySelector(`[${owned}=browser-add]`);
|
|
174
|
+
if (!item) return;
|
|
175
|
+
const previous = item.previousElementSibling, next = item.nextElementSibling;
|
|
176
|
+
let target;
|
|
177
|
+
// 注入项不属于 Radix collection,只接管与它相邻的键盘移动。
|
|
178
|
+
if (event.key === "ArrowDown" && event.target === previous || event.key === "ArrowUp" && event.target === next) target = item;
|
|
179
|
+
else if (event.target === item) {
|
|
180
|
+
if (event.key === "ArrowUp") target = previous;
|
|
181
|
+
else if (event.key === "ArrowDown") target = next;
|
|
182
|
+
else if (event.key === "Enter" || event.key === " ") { event.preventDefault(); event.stopImmediatePropagation(); item.click(); return; }
|
|
159
183
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
184
|
+
if (target) { event.preventDefault(); event.stopImmediatePropagation(); target.focus(); }
|
|
185
|
+
}
|
|
186
|
+
function showBrowserAdd({ site, project, preferProject }) {
|
|
187
|
+
const d = newDialog(text("Add to Sites", "添加到站点"));
|
|
188
|
+
let useProject = preferProject;
|
|
189
|
+
const modes = element("div", "mcx-site-modes"); modes.setAttribute("role", "tablist");
|
|
190
|
+
modes.setAttribute("aria-label", text("Site type", "站点类型"));
|
|
191
|
+
const caption = element("p");
|
|
192
|
+
const tabs = [text("Project", "项目"), text("Custom", "自定义")].map((label, index) => {
|
|
193
|
+
const tab = element("button", "mcx-site-mode", label); tab.type = "button"; tab.setAttribute("role", "tab");
|
|
194
|
+
tab.disabled = index === 0 && !project;
|
|
195
|
+
tab.onclick = () => { useProject = index === 0; update(); };
|
|
196
|
+
modes.append(tab); return tab;
|
|
165
197
|
});
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
198
|
+
function update() {
|
|
199
|
+
tabs.forEach((tab, index) => tab.setAttribute("aria-selected", String(useProject === (index === 0))));
|
|
200
|
+
caption.textContent = useProject ? project.name : text("Saved as a custom URL", "保存为自定义网址");
|
|
201
|
+
}
|
|
202
|
+
modes.onkeydown = event => {
|
|
203
|
+
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
|
|
204
|
+
event.preventDefault();
|
|
205
|
+
const index = event.key === "Home" ? (project ? 0 : 1) : event.key === "End" ? 1 : useProject || !project ? 1 : 0;
|
|
206
|
+
tabs[index].click(); tabs[index].focus();
|
|
207
|
+
};
|
|
208
|
+
d.append(modes, caption); update();
|
|
209
|
+
mountEditor(d, d, site, null, { getOrigin: () => useProject ? { kind: "project", projectId: project.id } : { kind: "external" } });
|
|
169
210
|
}
|
|
170
211
|
function reconcile() {
|
|
171
212
|
if (disposed) return;
|
|
213
|
+
updateBrowserMenu();
|
|
172
214
|
const page = nativeSites.pageInfo();
|
|
173
|
-
if (!page) {
|
|
174
|
-
cacheNative(page);
|
|
215
|
+
if (!page) { addButton?.remove(); addButton = null; rowsRoot?.remove(); rowsRoot = null; fallback?.remove(); fallback = null; rowSignature = ""; currentPage = null; closeMenu(); return; }
|
|
216
|
+
cacheNative(page);
|
|
175
217
|
if (currentPage !== page.page) { currentPage = page.page; rowSignature = ""; if (Date.now() - refreshAt > 1500) refresh().catch(e => notice(e.message)); }
|
|
176
218
|
if (!addButton?.isConnected && page.create) {
|
|
177
219
|
addButton = button(text("Add site", "添加站点"), () => showDiscovery()); addButton.setAttribute(owned, "add");
|
|
@@ -179,23 +221,21 @@ function reconcile() {
|
|
|
179
221
|
page.create.before(addButton);
|
|
180
222
|
}
|
|
181
223
|
const query = page.input.value.trim().toLowerCase();
|
|
182
|
-
const sites =
|
|
183
|
-
const signature = JSON.stringify([revision, query, page.shared, page.grid
|
|
224
|
+
const sites = state.sites.filter(s => [s.title, s.url || "", state.projects.find(p => p.id === s.origin.projectId)?.name || ""].some(v => v.toLowerCase().includes(query)));
|
|
225
|
+
const signature = JSON.stringify([revision, query, page.shared, page.grid]);
|
|
184
226
|
if (signature === rowSignature && rowsRoot?.isConnected && (page.list ? rowsRoot.parentElement === page.list : fallback?.isConnected)) return;
|
|
185
227
|
rowsRoot?.remove(); fallback?.remove(); fallback = null;
|
|
186
228
|
document.querySelectorAll("[data-mcx-sites-empty-suppressed]").forEach(e => e.removeAttribute("data-mcx-sites-empty-suppressed"));
|
|
187
229
|
rowsRoot = element("div"); rowsRoot.setAttribute(owned, "rows"); rowSignature = signature;
|
|
188
|
-
if (!sites.length
|
|
189
|
-
if (
|
|
230
|
+
if (!sites.length) { rowsRoot = null; return; }
|
|
231
|
+
if (page.empty) page.empty.setAttribute("data-mcx-sites-empty-suppressed", "");
|
|
190
232
|
if (page.list) page.list.append(rowsRoot);
|
|
191
233
|
else {
|
|
192
234
|
fallback = element("div", "@container pb-3"); fallback.setAttribute(owned, "fallback");
|
|
193
235
|
const layout = element("div"); layout.setAttribute(owned, page.grid ? "grid" : "table"); fallback.append(layout); layout.append(rowsRoot);
|
|
194
|
-
if (page.content.firstElementChild) page.content.firstElementChild.setAttribute("data-mcx-sites-empty-suppressed", "");
|
|
195
236
|
page.content.append(fallback);
|
|
196
237
|
}
|
|
197
238
|
rowsRoot.append(...sites.map(s => row(s, page.grid)));
|
|
198
|
-
if (addedOnly && !sites.length) { const empty = element("div", "col-span-full py-8 text-center text-sm text-secondary", text("No added sites", "暂无添加的站点")); rowsRoot.append(empty); }
|
|
199
239
|
}
|
|
200
240
|
function schedule() { clearTimeout(timer); timer = setTimeout(reconcile, 60); }
|
|
201
241
|
function newDialog(title, back) {
|
|
@@ -274,15 +314,16 @@ async function mountDiscovery(d, panel) {
|
|
|
274
314
|
});
|
|
275
315
|
return;
|
|
276
316
|
}
|
|
277
|
-
|
|
278
|
-
|
|
317
|
+
const available = candidates.filter(candidate => !completed.has(candidate) && !state.sites.some(s =>
|
|
318
|
+
(candidate.entryId && s.entryId === candidate.entryId || candidate.url && s.url === candidate.url)));
|
|
319
|
+
if (!available.length) results.append(element("div", "mcx-site-empty", text("No new project websites found", "暂无可添加的项目网页")));
|
|
320
|
+
for (const candidate of available) {
|
|
279
321
|
const item = element("label", "mcx-site-candidate");
|
|
280
322
|
const art = element("span", "mcx-site-candidate-art"); if (nativeSiteIcon) art.append(nativeSiteIcon.cloneNode(true));
|
|
281
323
|
const info = element("div", "mcx-site-candidate-info"); info.append(element("div", "", candidate.title), element("div", "mcx-site-url", siteSecondary(candidate)));
|
|
282
|
-
const added = completed.has(candidate) || state.sites.some(s => (candidate.entryId && s.entryId === candidate.entryId || candidate.url && s.url === candidate.url) && s.origin.projectId === candidate.projectId);
|
|
283
324
|
const checkbox = element("input", "mcx-site-checkbox"); checkbox.type = "checkbox";
|
|
284
|
-
checkbox.checked =
|
|
285
|
-
checkbox.setAttribute("aria-label",
|
|
325
|
+
checkbox.checked = selected.has(candidate); checkbox.disabled = saving;
|
|
326
|
+
checkbox.setAttribute("aria-label", candidate.title);
|
|
286
327
|
checkbox.onchange = () => { if (checkbox.checked) selected.add(candidate); else selected.delete(candidate); updateActions(); };
|
|
287
328
|
item.append(art, info, checkbox); if (candidate.url) loadPreview(art, candidate.url, candidate.status !== "running"); else art.classList.add("mcx-site-placeholder");
|
|
288
329
|
results.append(item);
|
|
@@ -300,14 +341,14 @@ function showEditor(site, problem) {
|
|
|
300
341
|
const d = newDialog(text("Edit site", "编辑站点"));
|
|
301
342
|
mountEditor(d, d, site, problem).focus();
|
|
302
343
|
}
|
|
303
|
-
function mountEditor(d, container, site, problem) {
|
|
344
|
+
function mountEditor(d, container, site, problem, options = {}) {
|
|
304
345
|
const form = element("form"), content = element("div", "mcx-site-content"); form.append(content); container.append(form);
|
|
305
346
|
function field(label, key, value, type) { const l = element("label", "", label), i = element("input"); i.id = `mcx-site-${key}`; l.htmlFor = i.id; i.name = key; i.value = value || ""; i.type = type || "text"; content.append(l, i); return i; }
|
|
306
347
|
const url = field(text("URL", "网址"), "url", site?.url, "url"); url.required = true; url.placeholder = "http://localhost:3000";
|
|
307
348
|
const title = field(text("Name", "名称"), "title", site?.title); title.maxLength = 160; title.required = true;
|
|
308
349
|
let origin = site?.origin || { kind: "external" };
|
|
309
350
|
const error = element("div", "", problem); error.setAttribute("role", "alert"); content.append(error);
|
|
310
|
-
const footer = config.ui.footer(element("div", "mcx-site-footer")); const cancel = dialogButton(text("Cancel", "取消"), () => d.close(), false, "ghost"); const save = dialogButton(site ? text("Save", "保存") : text("Add", "添加"), () => {}, true); save.type = "submit"; footer.append(cancel, save); form.append(footer);
|
|
351
|
+
const footer = config.ui.footer(element("div", "mcx-site-footer")); const cancel = dialogButton(text("Cancel", "取消"), () => d.close(), false, "ghost"); const save = dialogButton(site?.id ? text("Save", "保存") : text("Add", "添加"), () => {}, true); save.type = "submit"; footer.append(cancel, save); form.append(footer);
|
|
311
352
|
let submitting = false;
|
|
312
353
|
function validFields() {
|
|
313
354
|
if (!title.value.trim() || title.value.trim().length > 160 || !url.value.trim() || !url.validity.valid) return false;
|
|
@@ -319,7 +360,7 @@ function mountEditor(d, container, site, problem) {
|
|
|
319
360
|
form.onsubmit = async e => {
|
|
320
361
|
e.preventDefault(); if (submitting || !validFields()) { updateSave(); return; }
|
|
321
362
|
submitting = true; updateSave(); error.textContent = "";
|
|
322
|
-
try { await host({ operation: "save", id: site?.id, site: { title: title.value.trim(), url: url.value.trim(), origin } }); await refresh(); d.close(); }
|
|
363
|
+
try { await host({ operation: "save", id: site?.id, site: { title: title.value.trim(), url: url.value.trim(), origin: options.getOrigin?.() || origin } }); await refresh(); d.close(); }
|
|
323
364
|
catch (e) { error.textContent = e.message; }
|
|
324
365
|
finally { submitting = false; updateSave(); }
|
|
325
366
|
};
|
|
@@ -356,7 +397,6 @@ async function openSite(site) {
|
|
|
356
397
|
finally { opening = false; }
|
|
357
398
|
}
|
|
358
399
|
function onClick(e) {
|
|
359
|
-
if (addedOnly && e.target.closest('[aria-label="Site ownership"] button:not([data-mcx-sites])')) { addedOnly = false; rowSignature = ""; schedule(); }
|
|
360
400
|
if (menu && !menu.node.contains(e.target) && !menu.trigger.contains(e.target)) closeMenu();
|
|
361
401
|
if (e.target.closest(`[${owned}]`)) return;
|
|
362
402
|
const row = e.target.closest("[data-appgen-row], [data-testid=sites-grid] > article");
|
|
@@ -367,7 +407,7 @@ function onClick(e) {
|
|
|
367
407
|
const token = {}; pendingLayout = token; void applyWebLayout(token);
|
|
368
408
|
}
|
|
369
409
|
}
|
|
370
|
-
function onKey(e) { if (e.key === "Escape" && menu) { e.preventDefault(); closeMenu(); } else if (e.key === "Tab" && menu) closeMenu(); }
|
|
410
|
+
function onKey(e) { browserMenuKey(e); if (e.defaultPrevented) return; if (e.key === "Escape" && menu) { e.preventDefault(); closeMenu(); } else if (e.key === "Tab" && menu) closeMenu(); }
|
|
371
411
|
function onPointer(e) { if (e.target.closest('[role="separator"]')) pendingLayout = null; }
|
|
372
412
|
const observer = new MutationObserver(records => { if (records.some(r => !r.target.closest?.(`[${owned}]`))) schedule(); });
|
|
373
413
|
observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["aria-pressed"] });
|
|
@@ -376,8 +416,6 @@ refresh().catch(e => notice(e.message)); schedule();
|
|
|
376
416
|
return () => {
|
|
377
417
|
disposed = true; pendingLayout = null; clearTimeout(timer); observer.disconnect();
|
|
378
418
|
document.removeEventListener("input", schedule); document.removeEventListener("click", onClick, true); document.removeEventListener("keydown", onKey, true); document.removeEventListener("pointerdown", onPointer, true);
|
|
379
|
-
addedOnly = false; const page = nativeSites.pageInfo(); if (page) updateAddedTab(page);
|
|
380
|
-
document.querySelectorAll("[data-mcx-sites-native-hidden]").forEach(e => e.removeAttribute("data-mcx-sites-native-hidden"));
|
|
381
419
|
closeMenu(); dialog?.close(); document.querySelectorAll(`[${owned}]`).forEach(e => e.remove());
|
|
382
420
|
document.querySelectorAll("[data-mcx-sites-empty-suppressed]").forEach(e => e.removeAttribute("data-mcx-sites-empty-suppressed"));
|
|
383
421
|
};
|