minecodex 2.0.5 → 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.
Files changed (82) hide show
  1. package/DESIGN.md +15 -0
  2. package/README.md +5 -1
  3. package/apps/dashboard/DESIGN.md +53 -31
  4. package/apps/dashboard/app.js +55 -39
  5. package/apps/dashboard/assets/OFL.txt +93 -0
  6. package/apps/dashboard/assets/info.svg +15 -0
  7. package/apps/dashboard/assets/moon.svg +13 -0
  8. package/apps/dashboard/assets/refresh-cw.svg +16 -0
  9. package/apps/dashboard/assets/silkscreen.ttf +0 -0
  10. package/apps/dashboard/assets/sun.svg +21 -0
  11. package/apps/dashboard/assets/terrain.js +89 -0
  12. package/apps/dashboard/assets/triangle-alert.svg +15 -0
  13. package/apps/dashboard/favicon.svg +12 -17
  14. package/apps/dashboard/index.html +24 -18
  15. package/apps/dashboard/styles.css +147 -181
  16. package/assets/brand/README.md +19 -0
  17. package/assets/brand/minecodex-icon-dark.svg +9 -0
  18. package/assets/brand/minecodex-icon-light.svg +9 -0
  19. package/assets/brand/minecodex-icon.svg +8 -0
  20. package/assets/brand/references/lucide-LICENSE.txt +43 -0
  21. package/assets/readme/hero.svg +93 -0
  22. package/features/hide-upsell-banner/README.md +7 -5
  23. package/features/hide-upsell-banner/codex-feature.json +2 -2
  24. package/features/hide-upsell-banner/src/hide-upsell-banner.mjs +30 -12
  25. package/features/images/DESIGN.md +13 -2
  26. package/features/images/README.md +4 -3
  27. package/features/images/codex-feature.json +2 -2
  28. package/features/images/src/adapters/codex/native-image-modules.mjs +211 -0
  29. package/features/images/src/adapters/codex/native-image-source.mjs +70 -177
  30. package/features/images/src/frontend/cloud-image-actions.mjs +8 -6
  31. package/features/images/src/frontend/local-image-source.mjs +2 -1
  32. package/features/images/src/library-prompt-page.mjs +6 -1
  33. package/features/images/src/library-prompt-source.mjs +3 -5
  34. package/features/model-slider/codex-feature.json +2 -2
  35. package/features/notes/DESIGN.md +1 -1
  36. package/features/notes/codex-feature.json +2 -2
  37. package/features/notes/src/frontend/entry.mjs +1 -1
  38. package/features/session-status/codex-feature.json +2 -2
  39. package/features/sidebar-focus/DESIGN.md +4 -4
  40. package/features/sidebar-focus/codex-feature.json +3 -2
  41. package/features/sidebar-focus/src/adapters/codex/renderer.mjs +1 -1
  42. package/features/sidebar-focus/src/sidebar-focus.mjs +9 -7
  43. package/features/sites/DESIGN.md +25 -3
  44. package/features/sites/README.md +6 -2
  45. package/features/sites/codex-feature.json +3 -2
  46. package/features/sites/src/adapters/codex/renderer.mjs +25 -4
  47. package/features/sites/src/backend/project-sites.mjs +18 -6
  48. package/features/sites/src/backend/sites-service.mjs +22 -10
  49. package/features/sites/src/domain/discovery-candidates.mjs +53 -0
  50. package/features/sites/src/frontend/entry.mjs +76 -38
  51. package/features/sites/src/frontend/styles.css +3 -3
  52. package/features/unread-inbox/DESIGN.md +24 -25
  53. package/features/unread-inbox/codex-feature.json +3 -2
  54. package/features/unread-inbox/src/adapters/codex/modules.mjs +52 -0
  55. package/features/unread-inbox/src/adapters/codex/renderer.mjs +90 -0
  56. package/features/unread-inbox/src/domain/notifications.mjs +21 -0
  57. package/features/unread-inbox/src/injection-source.mjs +5 -2
  58. package/features/unread-inbox/src/unread-inbox.mjs +212 -297
  59. package/features/usage-meter/codex-feature.json +2 -2
  60. package/package.json +11 -3
  61. package/packages/cli/native/MineCodex.app/Contents/Info.plist +4 -2
  62. package/packages/cli/native/MineCodex.app/Contents/MacOS/MineCodexServiceManager +0 -0
  63. package/packages/cli/native/MineCodex.app/Contents/Resources/MineCodex.icns +0 -0
  64. package/packages/cli/native/MineCodex.app/Contents/_CodeSignature/CodeResources +11 -0
  65. package/packages/cli/native/README.md +31 -0
  66. package/packages/cli/src/browser-control-panel.mjs +48 -0
  67. package/packages/cli/src/commands.mjs +3 -1
  68. package/packages/cli/src/control-server.mjs +6 -3
  69. package/packages/cli/src/platform.mjs +5 -0
  70. package/packages/runtime-host/README.md +10 -5
  71. package/packages/runtime-host/src/codex-injection.mjs +10 -3
  72. package/packages/runtime-host/src/codex-runtime.mjs +10 -0
  73. package/packages/ui/DESIGN.md +5 -1
  74. package/packages/ui/assets/arrow-up-right.svg +14 -0
  75. package/packages/ui/assets/lucide-LICENSE.txt +43 -0
  76. package/packages/ui/src/components/account-menu.mjs +74 -0
  77. package/packages/ui/src/components/page.mjs +2 -2
  78. package/packages/ui/src/foundation/layers.mjs +1 -0
  79. package/packages/ui/src/install.mjs +3 -0
  80. package/packages/ui/src/native/account-menu.mjs +21 -0
  81. package/packages/ui/src/native/page.mjs +17 -2
  82. package/packages/ui/src/native/tokens.mjs +1 -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 FOCUS_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";
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 = FOCUS_GLYPH;
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("Exit focus", "退出聚焦") : text("Focus current task", "聚焦当前任务");
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
- // 与原生 View activity 共用 info / soft 激活态及明暗主题 token。
49
- button.setAttribute("data-color", active ? "info" : "secondary");
50
- button.setAttribute("data-variant", active ? "soft" : "ghost");
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.closest("[data-app-action-sidebar-section-toggle], " + projectSelector)) {
79
+ if (nativeFocus.isBranchToggle(event.target)) {
78
80
  active = false; states.clear(); schedule();
79
81
  }
80
82
  }, { capture:true, signal });
@@ -6,9 +6,9 @@
6
6
 
7
7
  ## 用户路径
8
8
 
9
- Sites 标题工具区增加次要操作 Add site,保留官方 Create。添加弹窗默认展示 Codex 本地项目中的网页入口候选(含未启动网站),不显示重复的项目分组标题,项目名称仅在候选次级文本呈现;候选使用 checkbox 多选,点击整行切换勾选;已收录项勾选且禁用。两个模式底部统一提供 Cancel / Add,项目模式未选择时禁用 Add,点击后才批量收录;取消不保存选择。候选不自动收录;扫描不启动服务。打开已收录的未启动站点时,使用支持的启动方式按需启动。弹窗使用「Project / Custom」分段按钮切换;切换保留已填写的网址;项目模式自动扫描并直接展示候选,不显示搜索框、说明行或刷新按钮。「自定义网址」只提供网址和名称,默认作为外部网址收录,在无项目聊天中打开;不显示开发来源及说明。网址与名称均必填;名称不能仅含空白且最多160字符,网址须为不含嵌入凭证的有效 HTTP(S) 地址。不符合时禁用 Add,输入变化后实时更新。项目关联自动来自扫描,不在编辑弹窗暴露。
9
+ Sites 标题工具区增加次要操作 Add site,保留官方 New。添加弹窗默认展示 Codex 本地项目中的网页入口候选(含未启动网站),不显示重复的项目分组标题,项目名称仅在候选次级文本呈现;候选使用 checkbox 多选,点击整行切换勾选;已收录项不再出现在候选列表中。两个模式底部统一提供 Cancel / Add,项目模式未选择时禁用 Add,点击后才批量收录;取消不保存选择。候选不自动收录;扫描不启动服务。打开已收录的未启动站点时,使用支持的启动方式按需启动。弹窗使用「Project / Custom」分段按钮切换;切换保留已填写的网址;项目模式自动扫描并直接展示候选,不显示搜索框、说明行或刷新按钮。「自定义网址」只提供网址和名称,默认作为外部网址收录,在无项目聊天中打开;不显示开发来源及说明。网址与名称均必填;名称不能仅含空白且最多160字符,网址须为不含嵌入凭证的有效 HTTP(S) 地址。不符合时禁用 Add,输入变化后实时更新。项目关联自动来自扫描,不在编辑弹窗暴露。
10
10
 
11
- 通过 MineCodex 添加的条目统一展示在 Added by you 标签的列表/网格,Owned by you 和 Shared with you 保持官方条目;使用同一搜索框。标题下只显示主机地址与端口;访问列用图标 + Local address / External site,沿用原生 16px 图标、主文字色、text-sm 与 gap-1.5。Added by you 同时包含 Local 和 External,访问列继续区分地址类型。操作列复用原生外部箭头,在默认浏览器打开;Project 条目菜单只提供移除,Custom URL 可编辑网址与名称,沿用添加校验。移除只移除入口。
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、Create、行菜单节点克隆 SVG;不重画。扫描结果和添加后的条目异步获取真实网页截图,本地缓存;完成后替换占位。没有截图时保留原生 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 和本机项目目录归属。
@@ -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; the Local address filter temporarily hides native rows and updates tab selection styling, restoring them on exit. 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/`.
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": "Discover project web services and open saved sites in their development context.",
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.className;
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.querySelector('[data-sidebar-destination="builtin:sites"] svg')?.cloneNode(true);
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
- found.push({ 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" });
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 { findProjectSites, entryKey } from "./project-sites.mjs";
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
- return { ...e, project };
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
- for (const [id, previous] of Object.entries(history)) {
71
- if (previous.directory && projectForDirectory(canonical.filter(p => p.id === previous.projectId), previous.directory) && !candidates.some(c => c.entryId === id) && !definitions.some(c => c.entryId === id)) definitions.push({ ...previous,entryId:id,status:"stopped" });
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({ ...definition, ...history[definition.entryId], url: running?.url || history[definition.entryId]?.url || null, status: running ? "running" : "stopped" });
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.sort((a,b) => a.projectName.localeCompare(b.projectName) || (a.url || a.directory).localeCompare(b.url || b.directory)), scope: "local-projects" };
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 addedTab, addedOnly = false, nativeExternal, nativeGlobe, nativeActionsClass, tabClasses;
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 updateAddedTab(page) {
151
- const group = page.page.querySelector('[aria-label="Site ownership"]');
152
- if (!group) return;
153
- const tabs = [...group.querySelectorAll("button")].filter(b => !b.hasAttribute(owned));
154
- if (tabs.length !== 2) return;
155
- if (!tabClasses) tabClasses = { active: tabs.find(b => b.getAttribute("aria-pressed") === "true")?.className, inactive: tabs.find(b => b.getAttribute("aria-pressed") === "false")?.className };
156
- if (!addedTab?.isConnected) {
157
- addedTab = tabs[1].cloneNode(false); addedTab.setAttribute(owned, "added-tab"); addedTab.textContent = text("Added by you", "由你添加");
158
- addedTab.onclick = () => { addedOnly = true; rowSignature = ""; reconcile(); }; group.append(addedTab);
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
- tabs.forEach((tab, i) => {
161
- const selected = !addedOnly && (i === (page.shared ? 1 : 0));
162
- const cls = selected ? tabClasses.active : tabClasses.inactive;
163
- if (tab.className !== cls) tab.className = cls;
164
- if (tab.getAttribute("aria-pressed") !== String(selected)) tab.setAttribute("aria-pressed", String(selected));
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
- addedTab.className = addedOnly ? tabClasses.active : tabClasses.inactive;
167
- addedTab.setAttribute("aria-pressed", String(addedOnly));
168
- page.list?.querySelectorAll("[data-appgen-row], article").forEach(r => r.toggleAttribute("data-mcx-sites-native-hidden", addedOnly));
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) { addedOnly = false; addedTab?.remove(); addedTab = null; addButton?.remove(); addButton = null; rowsRoot?.remove(); rowsRoot = null; fallback?.remove(); fallback = null; rowSignature = ""; currentPage = null; closeMenu(); return; }
174
- cacheNative(page); updateAddedTab(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 = !addedOnly ? [] : state.sites.filter(s => [s.title, s.url || "", state.projects.find(p => p.id === s.origin.projectId)?.name || ""].some(v => v.toLowerCase().includes(query)));
183
- const signature = JSON.stringify([revision, query, page.shared, page.grid, addedOnly]);
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 && !addedOnly) { rowsRoot = null; return; }
189
- if (addedOnly && !page.list && page.content.firstElementChild) page.content.firstElementChild.setAttribute("data-mcx-sites-empty-suppressed", "");
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
- if (!candidates.length) results.append(element("div", "mcx-site-empty", text("No project websites found", "尚未发现项目网页")));
278
- for (const candidate of candidates) {
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 = added || selected.has(candidate); checkbox.disabled = added || saving;
285
- checkbox.setAttribute("aria-label", (added ? text("Added: ", "已添加:") : "") + candidate.title);
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
  };