@duyxyz/saca 1.0.2 → 1.0.4
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/LICENSE +21 -21
- package/README.md +38 -38
- package/bin/cli.js +315 -315
- package/lib/adb.js +156 -156
- package/lib/state.js +144 -144
- package/lib/ui.js +205 -205
- package/package.json +50 -50
package/lib/adb.js
CHANGED
|
@@ -1,156 +1,156 @@
|
|
|
1
|
-
import { spawnSync } from 'child_process';
|
|
2
|
-
import { existsSync } from 'fs';
|
|
3
|
-
import { join, dirname } from 'path';
|
|
4
|
-
import { fileURLToPath } from 'url';
|
|
5
|
-
|
|
6
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Resolve path to adb binary.
|
|
10
|
-
* Priority: system PATH → local adb.exe in project root
|
|
11
|
-
*/
|
|
12
|
-
function getAdbPath() {
|
|
13
|
-
const test = spawnSync('adb', ['version'], { encoding: 'utf8', windowsHide: true });
|
|
14
|
-
if (test.status === 0) return 'adb';
|
|
15
|
-
|
|
16
|
-
// Fallback: adb.exe bundled in project root
|
|
17
|
-
const localAdb = join(__dirname, '..', 'adb.exe');
|
|
18
|
-
if (existsSync(localAdb)) return localAdb;
|
|
19
|
-
|
|
20
|
-
throw new Error('ADB not found in PATH or project directory.\nInstall ADB: https://developer.android.com/tools/adb');
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function runAdb(adbPath, args) {
|
|
24
|
-
return spawnSync(adbPath, args, {
|
|
25
|
-
encoding: 'utf8',
|
|
26
|
-
windowsHide: true,
|
|
27
|
-
});
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function readDeviceProp(adbPath, prop) {
|
|
31
|
-
const result = runAdb(adbPath, ['shell', 'getprop', prop]);
|
|
32
|
-
return (result.stdout || '').trim();
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Check ADB connection & detect connected device.
|
|
37
|
-
* @returns {string} path to adb binary
|
|
38
|
-
*/
|
|
39
|
-
export function checkAdb() {
|
|
40
|
-
const adbPath = getAdbPath();
|
|
41
|
-
|
|
42
|
-
const result = runAdb(adbPath, ['devices']);
|
|
43
|
-
|
|
44
|
-
if (result.error) throw new Error(`ADB error: ${result.error.message}`);
|
|
45
|
-
|
|
46
|
-
const lines = result.stdout
|
|
47
|
-
.trim()
|
|
48
|
-
.split('\n')
|
|
49
|
-
.slice(1) // Skip "List of devices attached" header
|
|
50
|
-
.map(l => l.trim())
|
|
51
|
-
.filter(l => l && !l.startsWith('*'));
|
|
52
|
-
|
|
53
|
-
if (lines.length === 0) {
|
|
54
|
-
throw new Error(
|
|
55
|
-
'No device connected.\nEnsure USB Debugging is enabled and the device is authorized.'
|
|
56
|
-
);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const [serial = '', state = 'unknown'] = lines[0].split(/\s+/);
|
|
60
|
-
|
|
61
|
-
if (state === 'unauthorized') {
|
|
62
|
-
throw new Error('Device detected but not authorized.\nAccept the RSA fingerprint prompt on the device.');
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
if (state !== 'device') {
|
|
66
|
-
throw new Error(`Device is not ready.\nCurrent state: ${state}`);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const model = readDeviceProp(adbPath, 'ro.product.model') || 'Unknown device';
|
|
70
|
-
const brand = readDeviceProp(adbPath, 'ro.product.brand');
|
|
71
|
-
const androidVersion = readDeviceProp(adbPath, 'ro.build.version.release') || 'Unknown';
|
|
72
|
-
|
|
73
|
-
return {
|
|
74
|
-
adbPath,
|
|
75
|
-
serial,
|
|
76
|
-
state,
|
|
77
|
-
model,
|
|
78
|
-
brand,
|
|
79
|
-
androidVersion,
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Quick check for connected devices serials.
|
|
85
|
-
* @returns {string[]} array of serial:state strings
|
|
86
|
-
*/
|
|
87
|
-
export function getDeviceSummary() {
|
|
88
|
-
try {
|
|
89
|
-
const adbPath = getAdbPath();
|
|
90
|
-
const result = runAdb(adbPath, ['devices']);
|
|
91
|
-
if (result.error) return [];
|
|
92
|
-
|
|
93
|
-
return result.stdout
|
|
94
|
-
.trim()
|
|
95
|
-
.split('\n')
|
|
96
|
-
.slice(1)
|
|
97
|
-
.map(l => l.trim())
|
|
98
|
-
.filter(l => l && !l.startsWith('*'))
|
|
99
|
-
.map(l => {
|
|
100
|
-
const [serial = '', state = 'unknown'] = l.split(/\s+/);
|
|
101
|
-
return `${serial}:${state}`;
|
|
102
|
-
});
|
|
103
|
-
} catch {
|
|
104
|
-
return [];
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Fetch installed packages split into system & 3rd party.
|
|
110
|
-
* @param {string} adbPath
|
|
111
|
-
* @returns {{ sysPackages: string[], userPackages: string[] }}
|
|
112
|
-
*/
|
|
113
|
-
export function getPackages(adbPath) {
|
|
114
|
-
const opts = { encoding: 'utf8', windowsHide: true };
|
|
115
|
-
|
|
116
|
-
// -s = system packages, --user 0 = primary user
|
|
117
|
-
const sysResult = spawnSync(
|
|
118
|
-
adbPath,
|
|
119
|
-
['shell', 'pm', 'list', 'packages', '-s', '--user', '0'],
|
|
120
|
-
opts
|
|
121
|
-
);
|
|
122
|
-
|
|
123
|
-
// -3 = 3rd party packages
|
|
124
|
-
const userResult = spawnSync(
|
|
125
|
-
adbPath,
|
|
126
|
-
['shell', 'pm', 'list', 'packages', '-3', '--user', '0'],
|
|
127
|
-
opts
|
|
128
|
-
);
|
|
129
|
-
|
|
130
|
-
const parse = (stdout) =>
|
|
131
|
-
stdout
|
|
132
|
-
.split('\n')
|
|
133
|
-
.map(l => l.replace('package:', '').trim())
|
|
134
|
-
.filter(Boolean)
|
|
135
|
-
.sort();
|
|
136
|
-
|
|
137
|
-
return {
|
|
138
|
-
sysPackages: parse(sysResult.stdout || ''),
|
|
139
|
-
userPackages: parse(userResult.stdout || ''),
|
|
140
|
-
};
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Uninstall a package for user 0 (disable without full removal).
|
|
145
|
-
* @param {string} adbPath
|
|
146
|
-
* @param {string} pkg package name
|
|
147
|
-
* @returns {boolean} true if success
|
|
148
|
-
*/
|
|
149
|
-
export function uninstallPackage(adbPath, pkg) {
|
|
150
|
-
const result = spawnSync(
|
|
151
|
-
adbPath,
|
|
152
|
-
['shell', 'pm', 'uninstall', '--user', '0', pkg],
|
|
153
|
-
{ encoding: 'utf8', windowsHide: true }
|
|
154
|
-
);
|
|
155
|
-
return (result.stdout || '').trim().toLowerCase().includes('success');
|
|
156
|
-
}
|
|
1
|
+
import { spawnSync } from 'child_process';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import { join, dirname } from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolve path to adb binary.
|
|
10
|
+
* Priority: system PATH → local adb.exe in project root
|
|
11
|
+
*/
|
|
12
|
+
function getAdbPath() {
|
|
13
|
+
const test = spawnSync('adb', ['version'], { encoding: 'utf8', windowsHide: true });
|
|
14
|
+
if (test.status === 0) return 'adb';
|
|
15
|
+
|
|
16
|
+
// Fallback: adb.exe bundled in project root
|
|
17
|
+
const localAdb = join(__dirname, '..', 'adb.exe');
|
|
18
|
+
if (existsSync(localAdb)) return localAdb;
|
|
19
|
+
|
|
20
|
+
throw new Error('ADB not found in PATH or project directory.\nInstall ADB: https://developer.android.com/tools/adb');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function runAdb(adbPath, args) {
|
|
24
|
+
return spawnSync(adbPath, args, {
|
|
25
|
+
encoding: 'utf8',
|
|
26
|
+
windowsHide: true,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readDeviceProp(adbPath, prop) {
|
|
31
|
+
const result = runAdb(adbPath, ['shell', 'getprop', prop]);
|
|
32
|
+
return (result.stdout || '').trim();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Check ADB connection & detect connected device.
|
|
37
|
+
* @returns {string} path to adb binary
|
|
38
|
+
*/
|
|
39
|
+
export function checkAdb() {
|
|
40
|
+
const adbPath = getAdbPath();
|
|
41
|
+
|
|
42
|
+
const result = runAdb(adbPath, ['devices']);
|
|
43
|
+
|
|
44
|
+
if (result.error) throw new Error(`ADB error: ${result.error.message}`);
|
|
45
|
+
|
|
46
|
+
const lines = result.stdout
|
|
47
|
+
.trim()
|
|
48
|
+
.split('\n')
|
|
49
|
+
.slice(1) // Skip "List of devices attached" header
|
|
50
|
+
.map(l => l.trim())
|
|
51
|
+
.filter(l => l && !l.startsWith('*'));
|
|
52
|
+
|
|
53
|
+
if (lines.length === 0) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
'No device connected.\nEnsure USB Debugging is enabled and the device is authorized.'
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const [serial = '', state = 'unknown'] = lines[0].split(/\s+/);
|
|
60
|
+
|
|
61
|
+
if (state === 'unauthorized') {
|
|
62
|
+
throw new Error('Device detected but not authorized.\nAccept the RSA fingerprint prompt on the device.');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (state !== 'device') {
|
|
66
|
+
throw new Error(`Device is not ready.\nCurrent state: ${state}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const model = readDeviceProp(adbPath, 'ro.product.model') || 'Unknown device';
|
|
70
|
+
const brand = readDeviceProp(adbPath, 'ro.product.brand');
|
|
71
|
+
const androidVersion = readDeviceProp(adbPath, 'ro.build.version.release') || 'Unknown';
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
adbPath,
|
|
75
|
+
serial,
|
|
76
|
+
state,
|
|
77
|
+
model,
|
|
78
|
+
brand,
|
|
79
|
+
androidVersion,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Quick check for connected devices serials.
|
|
85
|
+
* @returns {string[]} array of serial:state strings
|
|
86
|
+
*/
|
|
87
|
+
export function getDeviceSummary() {
|
|
88
|
+
try {
|
|
89
|
+
const adbPath = getAdbPath();
|
|
90
|
+
const result = runAdb(adbPath, ['devices']);
|
|
91
|
+
if (result.error) return [];
|
|
92
|
+
|
|
93
|
+
return result.stdout
|
|
94
|
+
.trim()
|
|
95
|
+
.split('\n')
|
|
96
|
+
.slice(1)
|
|
97
|
+
.map(l => l.trim())
|
|
98
|
+
.filter(l => l && !l.startsWith('*'))
|
|
99
|
+
.map(l => {
|
|
100
|
+
const [serial = '', state = 'unknown'] = l.split(/\s+/);
|
|
101
|
+
return `${serial}:${state}`;
|
|
102
|
+
});
|
|
103
|
+
} catch {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Fetch installed packages split into system & 3rd party.
|
|
110
|
+
* @param {string} adbPath
|
|
111
|
+
* @returns {{ sysPackages: string[], userPackages: string[] }}
|
|
112
|
+
*/
|
|
113
|
+
export function getPackages(adbPath) {
|
|
114
|
+
const opts = { encoding: 'utf8', windowsHide: true };
|
|
115
|
+
|
|
116
|
+
// -s = system packages, --user 0 = primary user
|
|
117
|
+
const sysResult = spawnSync(
|
|
118
|
+
adbPath,
|
|
119
|
+
['shell', 'pm', 'list', 'packages', '-s', '--user', '0'],
|
|
120
|
+
opts
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
// -3 = 3rd party packages
|
|
124
|
+
const userResult = spawnSync(
|
|
125
|
+
adbPath,
|
|
126
|
+
['shell', 'pm', 'list', 'packages', '-3', '--user', '0'],
|
|
127
|
+
opts
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const parse = (stdout) =>
|
|
131
|
+
stdout
|
|
132
|
+
.split('\n')
|
|
133
|
+
.map(l => l.replace('package:', '').trim())
|
|
134
|
+
.filter(Boolean)
|
|
135
|
+
.sort();
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
sysPackages: parse(sysResult.stdout || ''),
|
|
139
|
+
userPackages: parse(userResult.stdout || ''),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Uninstall a package for user 0 (disable without full removal).
|
|
145
|
+
* @param {string} adbPath
|
|
146
|
+
* @param {string} pkg package name
|
|
147
|
+
* @returns {boolean} true if success
|
|
148
|
+
*/
|
|
149
|
+
export function uninstallPackage(adbPath, pkg) {
|
|
150
|
+
const result = spawnSync(
|
|
151
|
+
adbPath,
|
|
152
|
+
['shell', 'pm', 'uninstall', '--user', '0', pkg],
|
|
153
|
+
{ encoding: 'utf8', windowsHide: true }
|
|
154
|
+
);
|
|
155
|
+
return (result.stdout || '').trim().toLowerCase().includes('success');
|
|
156
|
+
}
|
package/lib/state.js
CHANGED
|
@@ -1,144 +1,144 @@
|
|
|
1
|
-
export const state = {
|
|
2
|
-
phase: 'boot',
|
|
3
|
-
screen: 'loading',
|
|
4
|
-
message: 'Initializing session...',
|
|
5
|
-
error: '',
|
|
6
|
-
device: null,
|
|
7
|
-
items: [],
|
|
8
|
-
filteredByGroup: {
|
|
9
|
-
System: [],
|
|
10
|
-
User: [],
|
|
11
|
-
},
|
|
12
|
-
cursorByGroup: {
|
|
13
|
-
System: 0,
|
|
14
|
-
User: 0,
|
|
15
|
-
},
|
|
16
|
-
scrollByGroup: {
|
|
17
|
-
System: 0,
|
|
18
|
-
User: 0,
|
|
19
|
-
},
|
|
20
|
-
activePane: 'System',
|
|
21
|
-
query: '',
|
|
22
|
-
selected: new Set(),
|
|
23
|
-
pending: [],
|
|
24
|
-
currentPackage: '',
|
|
25
|
-
progressIndex: 0,
|
|
26
|
-
successCount: 0,
|
|
27
|
-
failCount: 0,
|
|
28
|
-
running: true,
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
export function clamp(value, min, max) {
|
|
32
|
-
return Math.max(min, Math.min(max, value));
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function createItems(sysPackages, userPackages) {
|
|
36
|
-
return [
|
|
37
|
-
...sysPackages.map((pkg) => ({ pkg, group: 'System' })),
|
|
38
|
-
...userPackages.map((pkg) => ({ pkg, group: 'User' })),
|
|
39
|
-
];
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function filterItems() {
|
|
43
|
-
const query = state.query.trim().toLowerCase();
|
|
44
|
-
state.filteredByGroup.System = state.items.filter((item) => (
|
|
45
|
-
item.group === 'System' &&
|
|
46
|
-
(!query || item.pkg.toLowerCase().includes(query) || item.group.toLowerCase().includes(query))
|
|
47
|
-
));
|
|
48
|
-
state.filteredByGroup.User = state.items.filter((item) => (
|
|
49
|
-
item.group === 'User' &&
|
|
50
|
-
(!query || item.pkg.toLowerCase().includes(query) || item.group.toLowerCase().includes(query))
|
|
51
|
-
));
|
|
52
|
-
|
|
53
|
-
for (const group of ['System', 'User']) {
|
|
54
|
-
const list = state.filteredByGroup[group];
|
|
55
|
-
if (list.length === 0) {
|
|
56
|
-
state.cursorByGroup[group] = 0;
|
|
57
|
-
state.scrollByGroup[group] = 0;
|
|
58
|
-
continue;
|
|
59
|
-
}
|
|
60
|
-
state.cursorByGroup[group] = clamp(state.cursorByGroup[group], 0, list.length - 1);
|
|
61
|
-
syncScroll(group);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
if (state.filteredByGroup[state.activePane].length === 0) {
|
|
65
|
-
state.activePane = state.filteredByGroup.System.length > 0 ? 'System' : 'User';
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
let getListHeightRef = () => 0;
|
|
70
|
-
|
|
71
|
-
export function setListHeightResolver(fn) {
|
|
72
|
-
getListHeightRef = fn;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function syncScroll(group = state.activePane) {
|
|
76
|
-
const list = state.filteredByGroup[group];
|
|
77
|
-
const listHeight = getListHeightRef();
|
|
78
|
-
if (listHeight <= 0 || list.length === 0) {
|
|
79
|
-
state.scrollByGroup[group] = 0;
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
if (state.cursorByGroup[group] < state.scrollByGroup[group]) {
|
|
83
|
-
state.scrollByGroup[group] = state.cursorByGroup[group];
|
|
84
|
-
}
|
|
85
|
-
const lastVisible = state.scrollByGroup[group] + listHeight - 1;
|
|
86
|
-
if (state.cursorByGroup[group] > lastVisible) {
|
|
87
|
-
state.scrollByGroup[group] = state.cursorByGroup[group] - listHeight + 1;
|
|
88
|
-
}
|
|
89
|
-
const maxScroll = Math.max(0, list.length - listHeight);
|
|
90
|
-
state.scrollByGroup[group] = clamp(state.scrollByGroup[group], 0, maxScroll);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export function moveCursor(delta) {
|
|
94
|
-
const group = state.activePane;
|
|
95
|
-
const list = state.filteredByGroup[group];
|
|
96
|
-
if (list.length === 0) return;
|
|
97
|
-
state.cursorByGroup[group] = clamp(state.cursorByGroup[group] + delta, 0, list.length - 1);
|
|
98
|
-
syncScroll(group);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export function switchPane(direction) {
|
|
102
|
-
const oldPane = state.activePane;
|
|
103
|
-
const nextPane = direction > 0 ? 'User' : 'System';
|
|
104
|
-
|
|
105
|
-
if (oldPane === nextPane) return;
|
|
106
|
-
|
|
107
|
-
const nextList = state.filteredByGroup[nextPane];
|
|
108
|
-
if (nextList.length > 0) {
|
|
109
|
-
// Get relative vertical position on screen
|
|
110
|
-
const relativeIndex = state.cursorByGroup[oldPane] - state.scrollByGroup[oldPane];
|
|
111
|
-
|
|
112
|
-
state.activePane = nextPane;
|
|
113
|
-
|
|
114
|
-
// Apply the same relative position to the new pane
|
|
115
|
-
state.cursorByGroup[nextPane] = clamp(
|
|
116
|
-
state.scrollByGroup[nextPane] + relativeIndex,
|
|
117
|
-
0,
|
|
118
|
-
nextList.length - 1
|
|
119
|
-
);
|
|
120
|
-
|
|
121
|
-
syncScroll(nextPane);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
export function toggleCurrent() {
|
|
126
|
-
const item = getCurrentItem();
|
|
127
|
-
if (!item) return;
|
|
128
|
-
if (state.selected.has(item.pkg)) {
|
|
129
|
-
state.selected.delete(item.pkg);
|
|
130
|
-
} else {
|
|
131
|
-
state.selected.add(item.pkg);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
export function getCurrentItem() {
|
|
136
|
-
const group = state.activePane;
|
|
137
|
-
return state.filteredByGroup[group][state.cursorByGroup[group]] || null;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
export function summaryStats() {
|
|
141
|
-
const systemCount = state.items.filter((item) => item.group === 'System').length;
|
|
142
|
-
const userCount = state.items.length - systemCount;
|
|
143
|
-
return { systemCount, userCount };
|
|
144
|
-
}
|
|
1
|
+
export const state = {
|
|
2
|
+
phase: 'boot',
|
|
3
|
+
screen: 'loading',
|
|
4
|
+
message: 'Initializing session...',
|
|
5
|
+
error: '',
|
|
6
|
+
device: null,
|
|
7
|
+
items: [],
|
|
8
|
+
filteredByGroup: {
|
|
9
|
+
System: [],
|
|
10
|
+
User: [],
|
|
11
|
+
},
|
|
12
|
+
cursorByGroup: {
|
|
13
|
+
System: 0,
|
|
14
|
+
User: 0,
|
|
15
|
+
},
|
|
16
|
+
scrollByGroup: {
|
|
17
|
+
System: 0,
|
|
18
|
+
User: 0,
|
|
19
|
+
},
|
|
20
|
+
activePane: 'System',
|
|
21
|
+
query: '',
|
|
22
|
+
selected: new Set(),
|
|
23
|
+
pending: [],
|
|
24
|
+
currentPackage: '',
|
|
25
|
+
progressIndex: 0,
|
|
26
|
+
successCount: 0,
|
|
27
|
+
failCount: 0,
|
|
28
|
+
running: true,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function clamp(value, min, max) {
|
|
32
|
+
return Math.max(min, Math.min(max, value));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createItems(sysPackages, userPackages) {
|
|
36
|
+
return [
|
|
37
|
+
...sysPackages.map((pkg) => ({ pkg, group: 'System' })),
|
|
38
|
+
...userPackages.map((pkg) => ({ pkg, group: 'User' })),
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function filterItems() {
|
|
43
|
+
const query = state.query.trim().toLowerCase();
|
|
44
|
+
state.filteredByGroup.System = state.items.filter((item) => (
|
|
45
|
+
item.group === 'System' &&
|
|
46
|
+
(!query || item.pkg.toLowerCase().includes(query) || item.group.toLowerCase().includes(query))
|
|
47
|
+
));
|
|
48
|
+
state.filteredByGroup.User = state.items.filter((item) => (
|
|
49
|
+
item.group === 'User' &&
|
|
50
|
+
(!query || item.pkg.toLowerCase().includes(query) || item.group.toLowerCase().includes(query))
|
|
51
|
+
));
|
|
52
|
+
|
|
53
|
+
for (const group of ['System', 'User']) {
|
|
54
|
+
const list = state.filteredByGroup[group];
|
|
55
|
+
if (list.length === 0) {
|
|
56
|
+
state.cursorByGroup[group] = 0;
|
|
57
|
+
state.scrollByGroup[group] = 0;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
state.cursorByGroup[group] = clamp(state.cursorByGroup[group], 0, list.length - 1);
|
|
61
|
+
syncScroll(group);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (state.filteredByGroup[state.activePane].length === 0) {
|
|
65
|
+
state.activePane = state.filteredByGroup.System.length > 0 ? 'System' : 'User';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let getListHeightRef = () => 0;
|
|
70
|
+
|
|
71
|
+
export function setListHeightResolver(fn) {
|
|
72
|
+
getListHeightRef = fn;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function syncScroll(group = state.activePane) {
|
|
76
|
+
const list = state.filteredByGroup[group];
|
|
77
|
+
const listHeight = getListHeightRef();
|
|
78
|
+
if (listHeight <= 0 || list.length === 0) {
|
|
79
|
+
state.scrollByGroup[group] = 0;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (state.cursorByGroup[group] < state.scrollByGroup[group]) {
|
|
83
|
+
state.scrollByGroup[group] = state.cursorByGroup[group];
|
|
84
|
+
}
|
|
85
|
+
const lastVisible = state.scrollByGroup[group] + listHeight - 1;
|
|
86
|
+
if (state.cursorByGroup[group] > lastVisible) {
|
|
87
|
+
state.scrollByGroup[group] = state.cursorByGroup[group] - listHeight + 1;
|
|
88
|
+
}
|
|
89
|
+
const maxScroll = Math.max(0, list.length - listHeight);
|
|
90
|
+
state.scrollByGroup[group] = clamp(state.scrollByGroup[group], 0, maxScroll);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function moveCursor(delta) {
|
|
94
|
+
const group = state.activePane;
|
|
95
|
+
const list = state.filteredByGroup[group];
|
|
96
|
+
if (list.length === 0) return;
|
|
97
|
+
state.cursorByGroup[group] = clamp(state.cursorByGroup[group] + delta, 0, list.length - 1);
|
|
98
|
+
syncScroll(group);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function switchPane(direction) {
|
|
102
|
+
const oldPane = state.activePane;
|
|
103
|
+
const nextPane = direction > 0 ? 'User' : 'System';
|
|
104
|
+
|
|
105
|
+
if (oldPane === nextPane) return;
|
|
106
|
+
|
|
107
|
+
const nextList = state.filteredByGroup[nextPane];
|
|
108
|
+
if (nextList.length > 0) {
|
|
109
|
+
// Get relative vertical position on screen
|
|
110
|
+
const relativeIndex = state.cursorByGroup[oldPane] - state.scrollByGroup[oldPane];
|
|
111
|
+
|
|
112
|
+
state.activePane = nextPane;
|
|
113
|
+
|
|
114
|
+
// Apply the same relative position to the new pane
|
|
115
|
+
state.cursorByGroup[nextPane] = clamp(
|
|
116
|
+
state.scrollByGroup[nextPane] + relativeIndex,
|
|
117
|
+
0,
|
|
118
|
+
nextList.length - 1
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
syncScroll(nextPane);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function toggleCurrent() {
|
|
126
|
+
const item = getCurrentItem();
|
|
127
|
+
if (!item) return;
|
|
128
|
+
if (state.selected.has(item.pkg)) {
|
|
129
|
+
state.selected.delete(item.pkg);
|
|
130
|
+
} else {
|
|
131
|
+
state.selected.add(item.pkg);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function getCurrentItem() {
|
|
136
|
+
const group = state.activePane;
|
|
137
|
+
return state.filteredByGroup[group][state.cursorByGroup[group]] || null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function summaryStats() {
|
|
141
|
+
const systemCount = state.items.filter((item) => item.group === 'System').length;
|
|
142
|
+
const userCount = state.items.length - systemCount;
|
|
143
|
+
return { systemCount, userCount };
|
|
144
|
+
}
|