Skip to content

Commit 369dcb1

Browse files
authored
Add project group Last updated sorting (#1059)
1 parent e3a3fc6 commit 369dcb1

5 files changed

Lines changed: 342 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ workarounds.
224224
| Open Target Discovery | Opt-in | `open-target-discovery` | [Docs](linux-features/open-target-discovery/README.md) |
225225
| Persistent status panel | Opt-in | `persistent-status-panel` | [Docs](linux-features/persistent-status-panel/README.md) |
226226
| Pet overlay | Opt-in | `pet-overlay` | [Docs](linux-features/pet-overlay/README.md) |
227+
| Project group Last updated sorting | Opt-in | `project-group-last-updated-sort` | [Docs](linux-features/project-group-last-updated-sort/README.md) |
227228
| Project task Created sorting | Opt-in | `project-task-sort` | [Docs](linux-features/project-task-sort/README.md) |
228229
| Read Aloud button | Opt-in | `read-aloud` | [Docs](linux-features/read-aloud/README.md) |
229230
| Read Aloud MCP | Opt-in | `read-aloud-mcp` | [Docs](linux-features/read-aloud-mcp/README.md) |
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Project Group Last Updated Sorting
2+
3+
Optional current-DMG patch for the Projects sidebar.
4+
5+
Upstream applies `Last updated` to task rows inside each project, but it then
6+
reapplies the saved manual project order to the project groups themselves. A
7+
complete saved order therefore leaves every project header fixed even when a
8+
different project has the newest task.
9+
10+
This feature makes `Last updated` sort both project groups and their task rows
11+
by recency. `Priority` and `Manual order` keep upstream's saved project-group
12+
ordering behavior.
13+
14+
The feature is disabled by default because it intentionally changes upstream
15+
sidebar semantics. Enable it in `linux-features/features.json`:
16+
17+
```json
18+
{
19+
"enabled": [
20+
"project-group-last-updated-sort"
21+
]
22+
}
23+
```
24+
25+
Run the feature tests with:
26+
27+
```bash
28+
node --test linux-features/project-group-last-updated-sort/test.js
29+
```
30+
31+
The patch targets only the current upstream Projects sidebar chunk. Upstream
32+
bundle drift leaves the asset unchanged and reports an optional patch warning.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"id": "project-group-last-updated-sort",
3+
"title": "Project Group Last Updated Sorting",
4+
"description": "Makes Last updated reorder project groups as well as their tasks in the Projects sidebar.",
5+
"defaultEnabled": false,
6+
"entrypoints": {
7+
"patchDescriptors": "./patch.js"
8+
}
9+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"use strict";
2+
3+
const currentGroupSorter =
4+
"function Fe({groups:e,items:t,projectOrder:n}){let r=new Map(t.map(e=>[e.task.key,e.recencyAt]));return ue(e.map((e,t)=>({group:e,index:t,recencyAt:Re(e,r)})).sort((e,t)=>t.recencyAt-e.recencyAt||e.index-t.index).map(({group:e})=>e),n)}";
5+
const patchedGroupSorter =
6+
"function Fe({groups:e,items:t,projectOrder:n,sortMode:codexLinuxProjectSortMode}){let r=new Map(t.map(e=>[e.task.key,e.recencyAt]));return((codexLinuxRecencySortedGroups)=>codexLinuxProjectSortMode===`updated_at`?codexLinuxRecencySortedGroups:ue(codexLinuxRecencySortedGroups,n))(e.map((e,t)=>({group:e,index:t,recencyAt:Re(e,r)})).sort((e,t)=>t.recencyAt-e.recencyAt||e.index-t.index).map(({group:e})=>e))}";
7+
8+
const currentGroupSorterCall =
9+
"T=Fe({groups:Pe({groups:S,items:c}),items:c,projectOrder:f(t,o.PROJECT_ORDER)})";
10+
const patchedGroupSorterCall =
11+
"T=Fe({groups:Pe({groups:S,items:c}),items:c,projectOrder:f(t,o.PROJECT_ORDER),sortMode:t(C).projectSortMode})";
12+
13+
function countOccurrences(source, needle) {
14+
return source.split(needle).length - 1;
15+
}
16+
17+
function applyProjectGroupLastUpdatedSortPatch(source) {
18+
const currentSorterCount = countOccurrences(source, currentGroupSorter);
19+
const patchedSorterCount = countOccurrences(source, patchedGroupSorter);
20+
const currentCallCount = countOccurrences(source, currentGroupSorterCall);
21+
const patchedCallCount = countOccurrences(source, patchedGroupSorterCall);
22+
23+
if (
24+
patchedSorterCount === 1 &&
25+
patchedCallCount === 1 &&
26+
currentSorterCount === 0 &&
27+
currentCallCount === 0
28+
) {
29+
return source;
30+
}
31+
32+
if (
33+
currentSorterCount !== 1 ||
34+
patchedSorterCount !== 0 ||
35+
currentCallCount !== 1 ||
36+
patchedCallCount !== 0
37+
) {
38+
console.warn(
39+
"WARN: Could not find current project group sorting insertion points - skipping project group Last updated sort feature patch",
40+
);
41+
return source;
42+
}
43+
44+
return source
45+
.replace(currentGroupSorter, patchedGroupSorter)
46+
.replace(currentGroupSorterCall, patchedGroupSorterCall);
47+
}
48+
49+
const descriptors = [
50+
{
51+
id: "last-updated-project-groups",
52+
phase: "webview-asset",
53+
order: 20_900,
54+
ciPolicy: "optional",
55+
pattern:
56+
/^app-initial~app-main~onboarding-page~projects-index-page~quick-chat-window-page~codex-micro~[A-Za-z0-9_-]+\.js$/,
57+
missingDescription: "project group sort webview bundle",
58+
skipDescription: "project group Last updated sorting feature patch",
59+
apply: applyProjectGroupLastUpdatedSortPatch,
60+
},
61+
];
62+
63+
module.exports = {
64+
applyProjectGroupLastUpdatedSortPatch,
65+
descriptors,
66+
};
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
#!/usr/bin/env node
2+
"use strict";
3+
4+
const assert = require("node:assert/strict");
5+
const fs = require("node:fs");
6+
const os = require("node:os");
7+
const path = require("node:path");
8+
const test = require("node:test");
9+
const vm = require("node:vm");
10+
11+
const {
12+
loadLinuxFeaturePatchDescriptors,
13+
} = require("../../scripts/lib/linux-features.js");
14+
const { patchAssetFiles } = require("../../scripts/patches/lib/assets.js");
15+
const {
16+
applyProjectGroupLastUpdatedSortPatch,
17+
descriptors,
18+
} = require("./patch.js");
19+
20+
const currentProjectSource = [
21+
"function ue(e,t){let n=new Map(t.map((e,t)=>[e,t]));return[...e].sort((e,t)=>(n.get(e.projectId)??2**53-1)-(n.get(t.projectId)??2**53-1))}",
22+
"function Re(e,t){let n=e.projectUpdatedAt??0;for(let r of e.threadKeys)n=Math.max(n,t.get(r)??0);return n}",
23+
"function Fe({groups:e,items:t,projectOrder:n}){let r=new Map(t.map(e=>[e.task.key,e.recencyAt]));return ue(e.map((e,t)=>({group:e,index:t,recencyAt:Re(e,r)})).sort((e,t)=>t.recencyAt-e.recencyAt||e.index-t.index).map(({group:e})=>e),n)}",
24+
"const prioritySortId=`sidebarElectron.sortMenu.priority`;",
25+
"const updatedSortId=`sidebarElectron.sortMenu.updated`;",
26+
"const manualSortId=`sidebarElectron.sortMenu.manual`;",
27+
"T=Fe({groups:Pe({groups:S,items:c}),items:c,projectOrder:f(t,o.PROJECT_ORDER)});",
28+
].join("");
29+
30+
function captureWarns(fn) {
31+
const originalWarn = console.warn;
32+
const warnings = [];
33+
console.warn = (message) => warnings.push(message);
34+
try {
35+
return { value: fn(), warnings };
36+
} finally {
37+
console.warn = originalWarn;
38+
}
39+
}
40+
41+
function applyPatchTwice(source) {
42+
const patched = applyProjectGroupLastUpdatedSortPatch(source);
43+
const { value: secondPass, warnings } = captureWarns(() =>
44+
applyProjectGroupLastUpdatedSortPatch(patched),
45+
);
46+
assert.equal(secondPass, patched);
47+
assert.deepEqual(warnings, []);
48+
return patched;
49+
}
50+
51+
function withFeatureConfig(enabled, fn) {
52+
const originalConfig = process.env.CODEX_LINUX_FEATURES_CONFIG;
53+
const tempDir = fs.mkdtempSync(
54+
path.join(os.tmpdir(), "project-group-last-updated-sort-"),
55+
);
56+
process.env.CODEX_LINUX_FEATURES_CONFIG = path.join(tempDir, "features.json");
57+
try {
58+
fs.writeFileSync(process.env.CODEX_LINUX_FEATURES_CONFIG, JSON.stringify({ enabled }));
59+
return fn();
60+
} finally {
61+
if (originalConfig == null) {
62+
delete process.env.CODEX_LINUX_FEATURES_CONFIG;
63+
} else {
64+
process.env.CODEX_LINUX_FEATURES_CONFIG = originalConfig;
65+
}
66+
fs.rmSync(tempDir, { recursive: true, force: true });
67+
}
68+
}
69+
70+
function evaluateGroupSorter(source) {
71+
const context = {};
72+
const sorterSource = source.slice(0, source.indexOf("const prioritySortId"));
73+
vm.runInNewContext(`${sorterSource};globalThis.sortProjectGroups=Fe`, context);
74+
return context.sortProjectGroups;
75+
}
76+
77+
test("feature is disabled until selected", () => {
78+
const featuresRoot = path.resolve(__dirname, "..");
79+
withFeatureConfig([], () => {
80+
assert.equal(
81+
loadLinuxFeaturePatchDescriptors({ featuresRoot }).some(
82+
(descriptor) =>
83+
descriptor.id ===
84+
"feature:project-group-last-updated-sort:last-updated-project-groups",
85+
),
86+
false,
87+
);
88+
});
89+
withFeatureConfig(["project-group-last-updated-sort"], () => {
90+
assert.equal(
91+
loadLinuxFeaturePatchDescriptors({ featuresRoot }).some(
92+
(descriptor) =>
93+
descriptor.id ===
94+
"feature:project-group-last-updated-sort:last-updated-project-groups",
95+
),
96+
true,
97+
);
98+
});
99+
});
100+
101+
test("Last updated sorts project groups by their newest task", () => {
102+
const patched = applyPatchTwice(currentProjectSource);
103+
const sortProjectGroups = evaluateGroupSorter(patched);
104+
const groups = [
105+
{ projectId: "nix", threadKeys: ["nix-task"] },
106+
{ projectId: "delta", threadKeys: ["delta-task"] },
107+
{ projectId: "multi", threadKeys: ["multi-task"] },
108+
{ projectId: "chezmoi", threadKeys: ["chezmoi-task"] },
109+
{ projectId: "tapas", threadKeys: ["tapas-task"] },
110+
];
111+
const items = [
112+
{ task: { key: "nix-task" }, recencyAt: 1 },
113+
{ task: { key: "delta-task" }, recencyAt: 2 },
114+
{ task: { key: "multi-task" }, recencyAt: 3 },
115+
{ task: { key: "chezmoi-task" }, recencyAt: 4 },
116+
{ task: { key: "tapas-task" }, recencyAt: 5 },
117+
];
118+
const projectOrder = ["nix", "delta", "multi", "chezmoi", "tapas"];
119+
120+
assert.deepEqual(
121+
Array.from(
122+
sortProjectGroups({ groups, items, projectOrder, sortMode: "updated_at" }),
123+
(group) => group.projectId,
124+
),
125+
["tapas", "chezmoi", "multi", "delta", "nix"],
126+
);
127+
});
128+
129+
test("non-updated modes preserve the upstream saved project order", () => {
130+
const patched = applyPatchTwice(currentProjectSource);
131+
const sortProjectGroups = evaluateGroupSorter(patched);
132+
const groups = [
133+
{ projectId: "newer", threadKeys: ["newer-task"] },
134+
{ projectId: "older", threadKeys: ["older-task"] },
135+
];
136+
const items = [
137+
{ task: { key: "newer-task" }, recencyAt: 2 },
138+
{ task: { key: "older-task" }, recencyAt: 1 },
139+
];
140+
const projectOrder = ["older", "newer"];
141+
142+
for (const sortMode of ["manual", "priority"]) {
143+
assert.deepEqual(
144+
Array.from(
145+
sortProjectGroups({ groups, items, projectOrder, sortMode }),
146+
(group) => group.projectId,
147+
),
148+
["older", "newer"],
149+
);
150+
}
151+
});
152+
153+
test("patch passes the selected project sort mode into the group sorter", () => {
154+
const patched = applyPatchTwice(currentProjectSource);
155+
assert.ok(
156+
patched.includes(
157+
"projectOrder:f(t,o.PROJECT_ORDER),sortMode:t(C).projectSortMode",
158+
),
159+
);
160+
});
161+
162+
test("drift leaves the asset byte-identical", () => {
163+
const source = currentProjectSource.replace(
164+
"function Fe({groups:e,items:t,projectOrder:n})",
165+
"function Fe({groups:e,items:t,projectOrder:n,unknown:o})",
166+
);
167+
const { value, warnings } = captureWarns(() =>
168+
applyProjectGroupLastUpdatedSortPatch(source),
169+
);
170+
171+
assert.equal(value, source);
172+
assert.equal(warnings.length, 1);
173+
assert.match(warnings[0], /project group sorting insertion points/);
174+
});
175+
176+
test("missing current call site leaves the asset byte-identical", () => {
177+
const source = currentProjectSource.replace(
178+
"projectOrder:f(t,o.PROJECT_ORDER)",
179+
"projectOrder:unknownProjectOrder",
180+
);
181+
const { value, warnings } = captureWarns(() =>
182+
applyProjectGroupLastUpdatedSortPatch(source),
183+
);
184+
185+
assert.equal(value, source);
186+
assert.equal(warnings.length, 1);
187+
assert.match(warnings[0], /project group sorting insertion points/);
188+
});
189+
190+
test("mixed patched and clean helpers are rejected byte-identically", () => {
191+
const mixed = `${applyProjectGroupLastUpdatedSortPatch(
192+
currentProjectSource,
193+
)}${currentProjectSource}`;
194+
const { value, warnings } = captureWarns(() =>
195+
applyProjectGroupLastUpdatedSortPatch(mixed),
196+
);
197+
198+
assert.equal(value, mixed);
199+
assert.equal(warnings.length, 1);
200+
assert.match(warnings[0], /project group sorting insertion points/);
201+
});
202+
203+
test("descriptor targets and patches only the current project sidebar chunk", () => {
204+
const tempDir = fs.mkdtempSync(
205+
path.join(os.tmpdir(), "project-group-last-updated-sort-assets-"),
206+
);
207+
try {
208+
const assetsDir = path.join(tempDir, "webview", "assets");
209+
const assetPath = path.join(
210+
assetsDir,
211+
"app-initial~app-main~onboarding-page~projects-index-page~quick-chat-window-page~codex-micro~iqsnin5k-demo.js",
212+
);
213+
fs.mkdirSync(assetsDir, { recursive: true });
214+
fs.writeFileSync(assetPath, currentProjectSource);
215+
216+
const result = patchAssetFiles(
217+
tempDir,
218+
descriptors[0].pattern,
219+
descriptors[0].apply,
220+
"missing",
221+
);
222+
223+
assert.deepEqual(result, { matched: 1, changed: 1 });
224+
assert.notEqual(fs.readFileSync(assetPath, "utf8"), currentProjectSource);
225+
assert.equal(
226+
descriptors[0].pattern.test(
227+
"app-initial~app-main~projects-index-page~remote-conversation-page-old.js",
228+
),
229+
false,
230+
);
231+
} finally {
232+
fs.rmSync(tempDir, { recursive: true, force: true });
233+
}
234+
});

0 commit comments

Comments
 (0)