pkgmate 0.0.1__py3-none-any.whl
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.
- pkgmate-0.0.1.dist-info/METADATA +10 -0
- pkgmate-0.0.1.dist-info/RECORD +36 -0
- pkgmate-0.0.1.dist-info/WHEEL +5 -0
- pkgmate-0.0.1.dist-info/entry_points.txt +2 -0
- pkgmate-0.0.1.dist-info/licenses/LICENSE +674 -0
- pkgmate-0.0.1.dist-info/top_level.txt +1 -0
- pkgtool/__init__.py +26 -0
- pkgtool/__main__.py +7 -0
- pkgtool/app.py +841 -0
- pkgtool/apt/__init__.py +9 -0
- pkgtool/apt/actions.py +193 -0
- pkgtool/apt/deps.py +94 -0
- pkgtool/apt/dpkg.py +153 -0
- pkgtool/apt/lists.py +336 -0
- pkgtool/apt/logs.py +83 -0
- pkgtool/apt/version.py +92 -0
- pkgtool/backends/__init__.py +30 -0
- pkgtool/backends/appimage.py +145 -0
- pkgtool/backends/deb.py +167 -0
- pkgtool/backends/flatpak.py +149 -0
- pkgtool/backends/linyap.py +110 -0
- pkgtool/backends/snap.py +106 -0
- pkgtool/base.py +238 -0
- pkgtool/catalog.py +345 -0
- pkgtool/classify.py +148 -0
- pkgtool/clean.py +288 -0
- pkgtool/cli.py +500 -0
- pkgtool/compress.py +56 -0
- pkgtool/config.py +236 -0
- pkgtool/inventory.py +167 -0
- pkgtool/labels.py +210 -0
- pkgtool/launch.py +156 -0
- pkgtool/remove.py +301 -0
- pkgtool/report.py +339 -0
- pkgtool/tui.py +473 -0
- pkgtool/upgrade.py +46 -0
pkgtool/app.py
ADDED
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
"""pkgtool.app — 交互式主界面(裸跑 `pkgtool` 进入)。
|
|
2
|
+
|
|
3
|
+
六个视图,Tab 或数字键切换:
|
|
4
|
+
1 全部包 2 可升级 3 散落包文件 4 仓库搜索 5 磁盘回收
|
|
5
|
+
|
|
6
|
+
本文件只有界面与状态机,业务规则一律走既有各层:inventory 取数、classify 判定、
|
|
7
|
+
remove / clean / upgrade / actions 执行、labels / report 出文案。这样交互界面和
|
|
8
|
+
子命令永远给出同一套结论——原 Web UI 就是因为自己重算了一遍分类和通道,
|
|
9
|
+
才和 CLI 对同一份数据给出不同答案。
|
|
10
|
+
|
|
11
|
+
所有会产生子进程的动作都通过 Screen.run_external 让出终端执行:sudo 密码提示
|
|
12
|
+
和命令输出需要真实终端,跟 curses 画面混在一起会花屏。
|
|
13
|
+
"""
|
|
14
|
+
import curses
|
|
15
|
+
import locale
|
|
16
|
+
import sys
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
from . import (catalog, clean, classify, inventory, labels, launch, remove,
|
|
20
|
+
report, tui, upgrade)
|
|
21
|
+
from .apt import actions, lists
|
|
22
|
+
from .config import CFG
|
|
23
|
+
from .report import pad, truncate
|
|
24
|
+
|
|
25
|
+
VIEWS = ("全部包", "可升级", "散落包文件", "仓库搜索", "磁盘回收")
|
|
26
|
+
_LIST, _UPGRADABLE, _LOOSE, _SEARCH, _CLEAN = range(len(VIEWS))
|
|
27
|
+
_SEARCH_LIMIT = 80
|
|
28
|
+
# t 键循环的包类型;flatpak 一项同时覆盖 flatpak 与 flatpak-runtime
|
|
29
|
+
_TYPE_CYCLE = ("all", "deb", "snap", "flatpak", "linyap", "appimage")
|
|
30
|
+
# 仓库搜索视图里 s 键循环的目录来源(见 catalog.py)
|
|
31
|
+
_SOURCE_CYCLE = ("all", "apt", "snap", "flatpak")
|
|
32
|
+
_SOURCE_LABEL = {"all": "全部来源", "apt": "apt 源", "snap": "Snap Store",
|
|
33
|
+
"flatpak": "flathub"}
|
|
34
|
+
|
|
35
|
+
_HELP = """按键一览
|
|
36
|
+
|
|
37
|
+
浏览
|
|
38
|
+
↑ ↓ / k j 上下移动 PgUp PgDn 翻页
|
|
39
|
+
g G 跳到开头 / 结尾 Tab / 1-6 切换视图
|
|
40
|
+
/ 搜索 / 过滤(apt 搜索视图里就是查询词)
|
|
41
|
+
t 包类型筛选:全部 → deb → snap → flatpak → appimage
|
|
42
|
+
s 显示层级:仅软件 → +依赖/数据 → 全部
|
|
43
|
+
R 重新采集 ? 本帮助
|
|
44
|
+
q 或 Esc 退出(详情里是返回)
|
|
45
|
+
|
|
46
|
+
对选中的包
|
|
47
|
+
Enter 查看详情 r 卸载(散落文件则是删除,先确认)
|
|
48
|
+
u 升级 d 下载 .deb(仅 deb,不需 root)
|
|
49
|
+
o 启动应用(分离启动,不阻塞本界面)
|
|
50
|
+
(r/o/u/d/T 只在适用的条目上出现;不适用的按键按了没反应)
|
|
51
|
+
|
|
52
|
+
磁盘回收视图(散落文件、用户缓存 ~/.cache、flatpak 应用缓存、
|
|
53
|
+
apt 下载缓存、snap 旧修订、flatpak 无用运行时、conda 包缓存)
|
|
54
|
+
Enter 删除当前项 空格 标记 / 取消标记
|
|
55
|
+
x 删除全部标记项 T 切换「移入回收站 / 真删」
|
|
56
|
+
|
|
57
|
+
仓库搜索视图(apt 源 / Snap Store / flathub 三源)
|
|
58
|
+
/ 输入关键词 s 切换来源(全部/apt/snap/flatpak)
|
|
59
|
+
Enter 详情:apt 列全部候选版本,snap 给 snap info,flatpak 给基本字段
|
|
60
|
+
i 安装:按来源自动选 apt-get / snap install / flatpak install
|
|
61
|
+
d 只下载 .deb(仅 apt 源)
|
|
62
|
+
|
|
63
|
+
三个来源的差别(见 catalog.py)
|
|
64
|
+
apt 读本地索引,离线、毫秒级,含描述与全部候选版本
|
|
65
|
+
snap 联网查 Snap Store,约 2 秒,含发布者/摘要;classic 会自动补 --classic
|
|
66
|
+
flatpak 读本地 remote 缓存,0.3 秒 / 3483 条,有体积但**没有描述**
|
|
67
|
+
(描述在 appstream 缓存里,本机没有;flatpak 官方的 search 命令
|
|
68
|
+
在这种情况下会联网拉取并挂死,所以不用它)
|
|
69
|
+
|
|
70
|
+
「大小」列的含义
|
|
71
|
+
12.3 MB 包自身的已安装体积
|
|
72
|
+
2.1 GB (14) 自身 + 14 个「独占依赖」的合计。独占依赖指只被这一个包
|
|
73
|
+
硬依赖、且 apt 标记为 auto 的包(取传递闭包)——删掉这个包
|
|
74
|
+
它们就没用了。库被多个包共用时不计入,所以微信、clash-verge
|
|
75
|
+
这类应用通常显示 0 个独占依赖。
|
|
76
|
+
注意这与卸载时实际释放的空间不是一回事:卸载预览走的是
|
|
77
|
+
apt 的 --autoremove 模拟,含反向依赖级联,数字通常更大。
|
|
78
|
+
|
|
79
|
+
说明:需要 root 的动作由 sudo 在终端上直接提示密码,本程序不经手密码。
|
|
80
|
+
卸载与清理都会先打印计划再确认;磁盘回收默认真删,按 T 可改成移入回收站。
|
|
81
|
+
散落包文件视图里 r 直接删除(主目录内默认真删,按 T 可改成移入回收站)。
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class Item:
|
|
87
|
+
kind: str # pkg / target / repo / hint
|
|
88
|
+
head: str
|
|
89
|
+
sub: str
|
|
90
|
+
data: object
|
|
91
|
+
color: str = "norm"
|
|
92
|
+
marked: bool = False
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _render(it, selected):
|
|
96
|
+
mark = f"{'*' if it.marked else ' '}{'>' if selected else ' '}"
|
|
97
|
+
color = "sel" if selected else it.color
|
|
98
|
+
return [(f"{mark} {it.head}", color), (it.sub, "dim")]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _ask_yes(prompt, default=False):
|
|
102
|
+
try:
|
|
103
|
+
a = input(prompt + (" [Y/n] " if default else " [y/N] ")).strip().lower()
|
|
104
|
+
except EOFError:
|
|
105
|
+
return default
|
|
106
|
+
return default if not a else a in ("y", "yes")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _pause(text="按 Enter 返回界面…"):
|
|
110
|
+
try:
|
|
111
|
+
input(text)
|
|
112
|
+
except (EOFError, KeyboardInterrupt):
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class App:
|
|
117
|
+
def __init__(self, cfg, inv):
|
|
118
|
+
self.cfg = cfg
|
|
119
|
+
self.inv = inv
|
|
120
|
+
self.view = _LIST
|
|
121
|
+
self.filter = {i: "" for i in range(len(VIEWS))}
|
|
122
|
+
self.level = 0 # 披露层级:0 仅软件 → 1 +依赖 → 2 全部
|
|
123
|
+
self.ptype = "all" # 包类型筛选,t 键循环
|
|
124
|
+
self.source = "all" # 仓库搜索的来源筛选,s 键循环
|
|
125
|
+
self.trash = False
|
|
126
|
+
self.items = []
|
|
127
|
+
self.lv = tui.ListView([], _render, per_item=2)
|
|
128
|
+
self.mode = "list" # list / detail
|
|
129
|
+
self.detail_lines = []
|
|
130
|
+
self.detail_rec = None # 详情对应的记录(供 r/u/d)
|
|
131
|
+
self.detail_item = None # 详情对应的仓库包名(供 i/d)
|
|
132
|
+
self.scroll = 0
|
|
133
|
+
self.msg = "按 ? 看帮助"
|
|
134
|
+
self._index = None
|
|
135
|
+
self.rebuild()
|
|
136
|
+
|
|
137
|
+
# ---------- 数据 ----------
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def q(self):
|
|
141
|
+
return self.filter[self.view].strip()
|
|
142
|
+
|
|
143
|
+
def index(self):
|
|
144
|
+
if self._index is None:
|
|
145
|
+
self._index = lists.load_index(self.cfg)
|
|
146
|
+
return self._index
|
|
147
|
+
|
|
148
|
+
def rebuild(self):
|
|
149
|
+
builders = (self._pkgs_all, self._pkgs_upgradable,
|
|
150
|
+
self._loose, self._repo_search, self._targets)
|
|
151
|
+
self.items = builders[self.view]()
|
|
152
|
+
self.lv.set_items(self.items)
|
|
153
|
+
|
|
154
|
+
def current(self):
|
|
155
|
+
return self.lv.current
|
|
156
|
+
|
|
157
|
+
def _sel_rec(self):
|
|
158
|
+
it = self.current()
|
|
159
|
+
if it and it.kind == "pkg" and it.data is not None:
|
|
160
|
+
return it.data
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
def _rec_hints(self, rec, detail=False):
|
|
164
|
+
"""按选中记录的实际能力出提示:不适用的按键不显示(按了也没反应),
|
|
165
|
+
而不是摆在那里等用户按了吃报错。"""
|
|
166
|
+
bits = []
|
|
167
|
+
if rec.is_loose_file:
|
|
168
|
+
bits.append("r 删除")
|
|
169
|
+
elif classify.is_removable(rec):
|
|
170
|
+
bits.append("r 卸载")
|
|
171
|
+
if launch.plan(rec):
|
|
172
|
+
bits.append("o 启动")
|
|
173
|
+
if not rec.is_loose_file and upgrade.plan(rec):
|
|
174
|
+
bits.append("u 升级")
|
|
175
|
+
if rec.pkg_type == "deb" and not rec.is_loose_file:
|
|
176
|
+
bits.append("d 下载")
|
|
177
|
+
if rec.is_loose_file:
|
|
178
|
+
bits.append("T 回收站")
|
|
179
|
+
if detail:
|
|
180
|
+
bits += ["↑↓ 滚动", "q 返回列表"]
|
|
181
|
+
return bits
|
|
182
|
+
|
|
183
|
+
def _pkg_item(self, r):
|
|
184
|
+
up = " ↑" if r.upgradable else ""
|
|
185
|
+
# 大小列右对齐后必须补一个空格,否则会和「通道」列粘成 "6.5 MBapt"
|
|
186
|
+
head = (pad(r.pkg_type, 10) + pad(truncate(r.name, 30), 32)
|
|
187
|
+
+ pad(truncate(r.version + up, 22), 24)
|
|
188
|
+
+ pad(labels.size_pair_text(r), 13, ">") + " "
|
|
189
|
+
+ pad(labels.channel_short(r.channel), 10)
|
|
190
|
+
+ pad(labels.class_text(r.pkg_class), 9)
|
|
191
|
+
+ pad((r.first_install or "—")[:10], 10))
|
|
192
|
+
bits = []
|
|
193
|
+
if r.origin_repos:
|
|
194
|
+
bits.append(labels.origin_short(r))
|
|
195
|
+
if r.install_path:
|
|
196
|
+
bits.append(truncate(r.install_path, 30))
|
|
197
|
+
if r.exclusive_deps:
|
|
198
|
+
bits.append(f"独占依赖 {len(r.exclusive_deps)} 个 "
|
|
199
|
+
f"{labels.size_text(r.deps_size_mb)}")
|
|
200
|
+
if r.executables:
|
|
201
|
+
bits.append(f"{len(r.executables)} 个可执行")
|
|
202
|
+
if r.upgradable:
|
|
203
|
+
bits.append("→ " + r.candidate)
|
|
204
|
+
color = "warn" if r.is_loose_file else ("ok" if r.upgradable else "norm")
|
|
205
|
+
return Item("pkg", head, " · ".join(bits), r, color)
|
|
206
|
+
|
|
207
|
+
def _pkgs_all(self):
|
|
208
|
+
return [self._pkg_item(r) for r in inventory.select(
|
|
209
|
+
self.inv, level=self.level, query=self.q, pkg_type=self.ptype)]
|
|
210
|
+
|
|
211
|
+
def _pkgs_upgradable(self):
|
|
212
|
+
return [self._pkg_item(r) for r in inventory.select(
|
|
213
|
+
self.inv, only_upgradable=True, level=self.level,
|
|
214
|
+
query=self.q, pkg_type=self.ptype)]
|
|
215
|
+
|
|
216
|
+
def _loose(self):
|
|
217
|
+
recs = inventory.select(self.inv, loose=True, query=self.q,
|
|
218
|
+
pkg_type=self.ptype)
|
|
219
|
+
recs.sort(key=lambda r: -r.size_mb)
|
|
220
|
+
return [Item("pkg",
|
|
221
|
+
pad(truncate(r.name, 30), 32)
|
|
222
|
+
+ pad(truncate(r.version, 24), 26)
|
|
223
|
+
+ pad(labels.size_text(r.size_mb), 11)
|
|
224
|
+
+ labels.loose_state(r),
|
|
225
|
+
r.extra.get("loose", ""), r, "warn") for r in recs]
|
|
226
|
+
|
|
227
|
+
def _targets(self):
|
|
228
|
+
q = self.q.lower()
|
|
229
|
+
out = []
|
|
230
|
+
for t in clean.collect_targets(self.cfg, inv=self.inv):
|
|
231
|
+
if q and q not in f"{t.kind} {t.label} {t.detail}".lower():
|
|
232
|
+
continue
|
|
233
|
+
how = " ".join(t.argv) if t.argv else "删除文件"
|
|
234
|
+
out.append(Item("target",
|
|
235
|
+
pad(t.size_text, 10) + " "
|
|
236
|
+
+ truncate(labels.clean_label(t.kind, t.label), 58),
|
|
237
|
+
" · ".join(x for x in (t.detail, how, t.note) if x),
|
|
238
|
+
t, "warn" if t.privileged else "norm"))
|
|
239
|
+
return out
|
|
240
|
+
|
|
241
|
+
def _repo_search(self):
|
|
242
|
+
if not self.q:
|
|
243
|
+
return [Item("hint",
|
|
244
|
+
"按 / 输入关键词,搜 apt 源 / Snap Store / flathub"
|
|
245
|
+
"(s 键切换来源)", "", None, "dim")]
|
|
246
|
+
hits = catalog.search(self.q,
|
|
247
|
+
sources=None if self.source == "all" else (self.source,),
|
|
248
|
+
limit=_SEARCH_LIMIT, cfg=self.cfg,
|
|
249
|
+
installed=catalog.installed_keys(self.inv))
|
|
250
|
+
if not hits:
|
|
251
|
+
note = (":" + ";".join(catalog.LAST_ERRORS)) if catalog.LAST_ERRORS else ""
|
|
252
|
+
return [Item("hint", f"未找到与 “{self.q}” 相关的条目{note}",
|
|
253
|
+
"", None, "dim")]
|
|
254
|
+
out = []
|
|
255
|
+
for x in hits:
|
|
256
|
+
# apt / snap 的显示名就是安装标识,重复占两列纯浪费宽度;
|
|
257
|
+
# flatpak 才有独立的显示名(Browser vs com.brave.Browser)
|
|
258
|
+
ident = "" if x.name == x.title else x.name
|
|
259
|
+
head = (pad(x.source, 8) + pad("已装" if x.installed else "", 5)
|
|
260
|
+
+ pad(truncate(x.title, 24), 26)
|
|
261
|
+
+ pad(truncate(ident, 28), 30)
|
|
262
|
+
+ pad(truncate(x.version or "—", 14), 16)
|
|
263
|
+
+ pad(x.size_text or "—", 9, ">"))
|
|
264
|
+
sub = " · ".join(t for t in (x.publisher, x.channel, x.summary) if t)
|
|
265
|
+
out.append(Item("repo", head, truncate(sub, 120), x,
|
|
266
|
+
"ok" if x.installed else "norm"))
|
|
267
|
+
return out
|
|
268
|
+
|
|
269
|
+
# ---------- 状态变更 ----------
|
|
270
|
+
|
|
271
|
+
def cycle_type(self):
|
|
272
|
+
"""t 键循环包类型筛选:全部 → deb → snap → flatpak → appimage。
|
|
273
|
+
直接走 inventory.select 的 pkg_type 参数,不另写一套过滤逻辑,
|
|
274
|
+
这样和 `pkgtool list -t xxx` 的结果永远一致。"""
|
|
275
|
+
i = _TYPE_CYCLE.index(self.ptype) if self.ptype in _TYPE_CYCLE else 0
|
|
276
|
+
self.ptype = _TYPE_CYCLE[(i + 1) % len(_TYPE_CYCLE)]
|
|
277
|
+
self.rebuild()
|
|
278
|
+
name = ("全部类型" if self.ptype == "all"
|
|
279
|
+
else labels.PKG_TYPE_LABEL.get(self.ptype, self.ptype))
|
|
280
|
+
self.msg = f"包类型:{name} → {len(self.items)} 项"
|
|
281
|
+
|
|
282
|
+
def cycle_level(self):
|
|
283
|
+
"""s 键循环披露层级:仅软件 → +依赖/数据 → 全部。
|
|
284
|
+
直接走 inventory.select 的 level 参数,与 `pkgtool list` 的 --all
|
|
285
|
+
共用一套过滤(--all 即层级 2)。"""
|
|
286
|
+
self.level = (self.level + 1) % 3
|
|
287
|
+
self.rebuild()
|
|
288
|
+
name = {0: "仅软件", 1: "软件 + 库/数据", 2: "全部(含系统/基础)"}[self.level]
|
|
289
|
+
self.msg = f"显示层级:{name} → {len(self.items)} 项"
|
|
290
|
+
|
|
291
|
+
def cycle_source(self):
|
|
292
|
+
"""s 键(仅在仓库搜索视图)循环目录来源。
|
|
293
|
+
snap 要联网约 2 秒、flatpak 首次要拉全量清单,能只搜一个源就只搜一个。"""
|
|
294
|
+
i = _SOURCE_CYCLE.index(self.source) if self.source in _SOURCE_CYCLE else 0
|
|
295
|
+
self.source = _SOURCE_CYCLE[(i + 1) % len(_SOURCE_CYCLE)]
|
|
296
|
+
self.rebuild()
|
|
297
|
+
el = " · ".join(f"{k} {v}s" for k, v in catalog.LAST_ELAPSED.items())
|
|
298
|
+
self.msg = (f"来源:{_SOURCE_LABEL[self.source]} → {len(self.items)} 条"
|
|
299
|
+
+ (f"({el})" if el else ""))
|
|
300
|
+
|
|
301
|
+
def set_view(self, v):
|
|
302
|
+
if v != self.view:
|
|
303
|
+
self.view = v
|
|
304
|
+
self.mode = "list"
|
|
305
|
+
self.rebuild()
|
|
306
|
+
|
|
307
|
+
def reload(self, scr):
|
|
308
|
+
def go():
|
|
309
|
+
print("\n重新采集中…")
|
|
310
|
+
self.inv = inventory.collect(
|
|
311
|
+
self.cfg, on_backend=lambda t, n, e, err: print(
|
|
312
|
+
f" [{t}] {n} 个 ({e:.1f}s)" + (f" 失败: {err}" if err else "")))
|
|
313
|
+
self._index = None
|
|
314
|
+
scr.run_external(go)
|
|
315
|
+
self.rebuild()
|
|
316
|
+
self.msg = f"已重新采集 {len(self.inv.records)} 条 @ {self.inv.collected_at}"
|
|
317
|
+
|
|
318
|
+
def _bump(self, rec):
|
|
319
|
+
if rec.candidate:
|
|
320
|
+
rec.version, rec.candidate = rec.candidate, ""
|
|
321
|
+
|
|
322
|
+
# ---------- 渲染 ----------
|
|
323
|
+
|
|
324
|
+
def draw(self, scr):
|
|
325
|
+
h, w = scr.h, scr.w
|
|
326
|
+
scr.clear()
|
|
327
|
+
if h < 10 or w < 60:
|
|
328
|
+
scr.text(0, 0, "终端窗口太小,请放大到至少 10 行 60 列")
|
|
329
|
+
scr.refresh()
|
|
330
|
+
return
|
|
331
|
+
scr.text(0, 0, truncate(" pkgtool · " + self._tabs(), w - 1), "title")
|
|
332
|
+
scr.text(1, 0, truncate(" " + " · ".join(self._status_bits()), w - 1), "dim")
|
|
333
|
+
body_top, body_h = 2, h - 5
|
|
334
|
+
if self.mode == "detail":
|
|
335
|
+
self._draw_detail(scr, body_top, body_h, w)
|
|
336
|
+
else:
|
|
337
|
+
head = self._header()
|
|
338
|
+
if head:
|
|
339
|
+
scr.text(body_top, 3, truncate(head, w - 4), "dim")
|
|
340
|
+
body_top += 1
|
|
341
|
+
body_h -= 1
|
|
342
|
+
self.lv.per_item = 2 if body_h >= 12 else 1
|
|
343
|
+
if not self.items:
|
|
344
|
+
scr.text(body_top + 1, 2, "(没有匹配的条目)", "dim")
|
|
345
|
+
else:
|
|
346
|
+
self.lv.draw(scr, body_top, body_h)
|
|
347
|
+
scr.hline(h - 3)
|
|
348
|
+
scr.text(h - 2, 0, " " + truncate(self._hints(), w - 2), "title")
|
|
349
|
+
scr.text(h - 1, 0, " " + truncate(self.msg, w - 2), "dim")
|
|
350
|
+
scr.refresh()
|
|
351
|
+
|
|
352
|
+
def _header(self):
|
|
353
|
+
"""列表视图的列头,列宽与对应的 _pkg_item / _repo_search 一一对应
|
|
354
|
+
(改一处必须改另一处)。其余视图各自排版,不加列头。"""
|
|
355
|
+
if self.view in (_LIST, _UPGRADABLE):
|
|
356
|
+
return (pad("类型", 10) + pad("名称", 32) + pad("版本", 24)
|
|
357
|
+
+ pad("大小", 13, ">") + " " + pad("通道", 10)
|
|
358
|
+
+ pad("类别", 9) + pad("首次安装", 10))
|
|
359
|
+
if self.view == _SEARCH and self.q:
|
|
360
|
+
return (pad("来源", 8) + pad("", 5) + pad("名称", 26)
|
|
361
|
+
+ pad("安装标识", 30) + pad("版本", 16)
|
|
362
|
+
+ pad("体积", 9, ">"))
|
|
363
|
+
return None
|
|
364
|
+
|
|
365
|
+
def _tabs(self):
|
|
366
|
+
return " ".join(f"[{i}]{n}" if i - 1 == self.view else f" {i} {n} "
|
|
367
|
+
for i, n in enumerate(VIEWS, start=1))
|
|
368
|
+
|
|
369
|
+
def _status_bits(self):
|
|
370
|
+
bits = [f"{len(self.items)} 项"]
|
|
371
|
+
sized = sum(i.data.size_mb for i in self.items
|
|
372
|
+
if i.kind == "pkg" and i.data is not None)
|
|
373
|
+
if sized:
|
|
374
|
+
bits.append("体积 " + labels.size_text(round(sized, 1)))
|
|
375
|
+
if self.q:
|
|
376
|
+
bits.append(f"过滤 “{self.q}”")
|
|
377
|
+
if self.ptype != "all":
|
|
378
|
+
bits.append("类型 " + labels.PKG_TYPE_LABEL.get(self.ptype, self.ptype))
|
|
379
|
+
if self.view == _LIST and self.level:
|
|
380
|
+
bits.append({1: "软件+依赖", 2: "全部层级"}[self.level])
|
|
381
|
+
if self.view == _SEARCH:
|
|
382
|
+
bits.append("来源 " + _SOURCE_LABEL[self.source])
|
|
383
|
+
el = " ".join(f"{k}{v}s" for k, v in catalog.LAST_ELAPSED.items())
|
|
384
|
+
if el:
|
|
385
|
+
bits.append(el)
|
|
386
|
+
if self.view == _CLEAN:
|
|
387
|
+
sized = sum(i.data.size_mb for i in self.items
|
|
388
|
+
if i.kind == "target" and i.data is not None
|
|
389
|
+
and i.data.size_known)
|
|
390
|
+
if sized:
|
|
391
|
+
bits.append("可回收 " + labels.size_text(round(sized, 1)))
|
|
392
|
+
return bits
|
|
393
|
+
|
|
394
|
+
def _draw_detail(self, scr, y0, body_h, w):
|
|
395
|
+
n = len(self.detail_lines)
|
|
396
|
+
self.scroll = max(0, min(self.scroll, max(0, n - body_h)))
|
|
397
|
+
for i in range(body_h):
|
|
398
|
+
j = self.scroll + i
|
|
399
|
+
if j >= n:
|
|
400
|
+
break
|
|
401
|
+
scr.text(y0 + i, 0, truncate(self.detail_lines[j], w - 1),
|
|
402
|
+
"title" if j == 0 else None)
|
|
403
|
+
if n > body_h:
|
|
404
|
+
scr.text(y0, max(0, w - 14), f" {self.scroll + 1}/{n} ", "dim")
|
|
405
|
+
|
|
406
|
+
def _hints(self):
|
|
407
|
+
if self.mode == "detail":
|
|
408
|
+
if self.detail_item:
|
|
409
|
+
bits = ["i 安装"]
|
|
410
|
+
if self.detail_item.source == "apt":
|
|
411
|
+
bits.append("d 下载")
|
|
412
|
+
bits += ["↑↓ 滚动", "q 返回列表"]
|
|
413
|
+
return " · ".join(bits)
|
|
414
|
+
if self.detail_rec:
|
|
415
|
+
return " · ".join(self._rec_hints(self.detail_rec, detail=True))
|
|
416
|
+
return "↑↓ 滚动 · q 返回列表"
|
|
417
|
+
if self.view == _CLEAN:
|
|
418
|
+
return ("Enter 删除 · 空格 标记 · x 删标记 · T 切回收站 · / 过滤 · "
|
|
419
|
+
"Tab 换视图 · ? 帮助 · q 退出")
|
|
420
|
+
if self.view == _SEARCH:
|
|
421
|
+
bits = ["/ 搜索", "s 切来源"]
|
|
422
|
+
it = self.current()
|
|
423
|
+
if it and it.kind == "repo":
|
|
424
|
+
bits += ["Enter 详情", "i 安装"]
|
|
425
|
+
if it.data.source == "apt":
|
|
426
|
+
bits.append("d 下载")
|
|
427
|
+
bits += ["? 帮助", "q 退出"]
|
|
428
|
+
return " · ".join(bits)
|
|
429
|
+
if self.view in (_LIST, _UPGRADABLE, _LOOSE):
|
|
430
|
+
bits = ["Enter 详情"]
|
|
431
|
+
rec = self._sel_rec()
|
|
432
|
+
if rec:
|
|
433
|
+
bits += self._rec_hints(rec)
|
|
434
|
+
bits += ["/ 搜索", "t 筛选"]
|
|
435
|
+
if self.view == _LIST:
|
|
436
|
+
bits.append("s 层级")
|
|
437
|
+
bits += ["R 重采", "? 帮助", "q 退出"]
|
|
438
|
+
return " · ".join(bits)
|
|
439
|
+
return "? 帮助 · q 退出"
|
|
440
|
+
|
|
441
|
+
# ---------- 详情 ----------
|
|
442
|
+
|
|
443
|
+
def open_detail(self):
|
|
444
|
+
it = self.current()
|
|
445
|
+
if it is None or it.data is None:
|
|
446
|
+
return
|
|
447
|
+
if it.kind == "pkg":
|
|
448
|
+
self.detail_rec, self.detail_item = it.data, None
|
|
449
|
+
self.detail_lines = report.render_detail(it.data).splitlines()
|
|
450
|
+
elif it.kind == "repo":
|
|
451
|
+
self.detail_rec, self.detail_item = None, it.data
|
|
452
|
+
self.detail_lines = catalog.describe(it.data, self.cfg).splitlines()
|
|
453
|
+
self.scroll = 0
|
|
454
|
+
self.mode = "detail"
|
|
455
|
+
|
|
456
|
+
def show_help(self):
|
|
457
|
+
self.detail_rec = self.detail_item = None
|
|
458
|
+
self.detail_lines = _HELP.splitlines()
|
|
459
|
+
self.scroll = 0
|
|
460
|
+
self.mode = "detail"
|
|
461
|
+
|
|
462
|
+
def close_detail(self):
|
|
463
|
+
self.mode = "list"
|
|
464
|
+
self.detail_rec = self.detail_item = None
|
|
465
|
+
|
|
466
|
+
# ---------- 动作(都在真实终端上执行)----------
|
|
467
|
+
|
|
468
|
+
def act_remove(self, scr, rec):
|
|
469
|
+
if not classify.is_removable(rec):
|
|
470
|
+
self.msg = f"不能卸载 {rec.name}:{labels.block_reason(rec.pkg_class)}"
|
|
471
|
+
return
|
|
472
|
+
box = {}
|
|
473
|
+
|
|
474
|
+
def go():
|
|
475
|
+
print()
|
|
476
|
+
print(report.render_detail(rec))
|
|
477
|
+
purge = _ask_yes("\n同时删除主目录下的配置/缓存残留?")
|
|
478
|
+
plan = remove.preview(rec, purge_residues=purge, cfg=self.cfg)
|
|
479
|
+
box["plan"] = plan
|
|
480
|
+
if not plan.ok:
|
|
481
|
+
print(f"\n拒绝卸载 {rec.name}:{plan.error}")
|
|
482
|
+
_pause()
|
|
483
|
+
return False
|
|
484
|
+
print(f"\n将移除 {len(plan.will_remove)} 个包:")
|
|
485
|
+
for p in plan.will_remove[:40]:
|
|
486
|
+
print(" " + p)
|
|
487
|
+
if len(plan.will_remove) > 40:
|
|
488
|
+
print(f" … 其余 {len(plan.will_remove) - 40} 个")
|
|
489
|
+
if plan.residues:
|
|
490
|
+
print(f"\n将删除 {len(plan.residues)} 处残留({plan.freed_mb} MB):")
|
|
491
|
+
for p, s in plan.residues:
|
|
492
|
+
print(f" {p} {s} MB")
|
|
493
|
+
print(f"\n等效命令:\n {plan.command_text or '(无需特权)'}")
|
|
494
|
+
if not _ask_yes(f"\n确认卸载 {rec.name}?"):
|
|
495
|
+
print("已取消")
|
|
496
|
+
_pause()
|
|
497
|
+
return False
|
|
498
|
+
res = remove.execute(plan, on_line=print, cfg=self.cfg)
|
|
499
|
+
print("✓ 卸载完成" if res.ok else f"✗ 命令报错:{res.error}")
|
|
500
|
+
# 卸载后立即重采:flatpak 偶发"命令报错但实际已卸掉",就地摘记录
|
|
501
|
+
# 不可靠,重采结果才是事实。本来就已在终端模式里,顺手做掉。
|
|
502
|
+
print("\n重新采集中…")
|
|
503
|
+
box["inv"] = inventory.collect(
|
|
504
|
+
self.cfg, on_backend=lambda t, n, e, err: print(
|
|
505
|
+
f" [{t}] {n} 个 ({e:.1f}s)" + (f" 失败: {err}" if err else "")))
|
|
506
|
+
return True
|
|
507
|
+
|
|
508
|
+
out = scr.run_external(go)
|
|
509
|
+
if out:
|
|
510
|
+
res_ok, gone = True, True
|
|
511
|
+
if "inv" in box:
|
|
512
|
+
new_inv = box["inv"]
|
|
513
|
+
self.inv = new_inv
|
|
514
|
+
gone = new_inv.by_key.get(rec.key) is None
|
|
515
|
+
self.close_detail()
|
|
516
|
+
self.rebuild()
|
|
517
|
+
self.msg = (f"已卸载 {rec.name}" if res_ok else
|
|
518
|
+
f"{rec.name} 卸载命令报错,已重采核实:"
|
|
519
|
+
+ ("确认已卸载" if gone else "记录仍在,未卸载"))
|
|
520
|
+
|
|
521
|
+
def act_upgrade(self, scr, rec):
|
|
522
|
+
def go():
|
|
523
|
+
print()
|
|
524
|
+
if upgrade.plan(rec) is None:
|
|
525
|
+
print(labels.update_advice(rec))
|
|
526
|
+
_pause()
|
|
527
|
+
return False
|
|
528
|
+
print(f"升级 {rec.pkg_type} {rec.name} {rec.version}"
|
|
529
|
+
+ (f" → {rec.candidate}" if rec.candidate else ""))
|
|
530
|
+
if not _ask_yes("执行?"):
|
|
531
|
+
return False
|
|
532
|
+
res = upgrade.run(rec, on_line=print, cfg=self.cfg)
|
|
533
|
+
print("✓ 完成" if res.ok else f"✗ 失败:{res.error}")
|
|
534
|
+
_pause()
|
|
535
|
+
return res.ok
|
|
536
|
+
|
|
537
|
+
if scr.run_external(go):
|
|
538
|
+
self._bump(rec)
|
|
539
|
+
self.close_detail()
|
|
540
|
+
self.rebuild()
|
|
541
|
+
self.msg = f"已升级 {rec.name}(按 R 重新采集可刷新全部数据)"
|
|
542
|
+
|
|
543
|
+
def act_open(self, rec):
|
|
544
|
+
"""启动应用。launch 是分离启动(Popen 后立即返回),不需要让出终端。"""
|
|
545
|
+
res = launch.launch(rec, cfg=self.cfg)
|
|
546
|
+
if res.ok:
|
|
547
|
+
self.msg = "已启动: " + " ".join(res.command)
|
|
548
|
+
else:
|
|
549
|
+
self.msg = f"无法启动 {rec.name}:{res.error}"
|
|
550
|
+
|
|
551
|
+
def act_delete_loose(self, scr, rec):
|
|
552
|
+
"""在散落包文件视图里就地删除,不必切到磁盘回收视图。
|
|
553
|
+
删除逻辑复用 clean 层(普通权限回收站/真删,越权的走 sudo),
|
|
554
|
+
保证和「磁盘回收」视图对同一文件的行为一致。"""
|
|
555
|
+
t = clean.loose_target(rec, self.cfg)
|
|
556
|
+
if t is None:
|
|
557
|
+
self.msg = "文件已不在原位置(按 R 重新采集可刷新列表)"
|
|
558
|
+
return
|
|
559
|
+
|
|
560
|
+
def go():
|
|
561
|
+
print(f"\n→ 删除散落文件:{t.detail}({t.size_text})")
|
|
562
|
+
print(" 方式:" + ("移入回收站" if self.trash and not t.privileged
|
|
563
|
+
else "真删" if not t.privileged else "sudo rm(文件在系统目录)"))
|
|
564
|
+
if not _ask_yes("确认删除?"):
|
|
565
|
+
print("已取消")
|
|
566
|
+
_pause()
|
|
567
|
+
return False
|
|
568
|
+
res = clean.delete(t, trash=self.trash, on_line=print, cfg=self.cfg)
|
|
569
|
+
print("✓ 已删除" if res.ok else f"✗ 失败:{res.error}")
|
|
570
|
+
_pause()
|
|
571
|
+
return res.ok
|
|
572
|
+
|
|
573
|
+
if scr.run_external(go):
|
|
574
|
+
self.inv.records = [r for r in self.inv.records if r.key != rec.key]
|
|
575
|
+
self.inv.by_key.pop(rec.key, None)
|
|
576
|
+
self.rebuild()
|
|
577
|
+
self.msg = f"已删除 {t.detail}"
|
|
578
|
+
|
|
579
|
+
def act_install_item(self, scr, item):
|
|
580
|
+
"""从目录安装(apt / snap / flatpak 三源统一入口)。
|
|
581
|
+
命令由 catalog.install_argv 生成:snap 的 classic confinement 必须带
|
|
582
|
+
--classic,flatpak 要带上 remote 名,这些差异都收在 catalog 里。"""
|
|
583
|
+
def go():
|
|
584
|
+
argv, err = catalog.install_argv(item)
|
|
585
|
+
if argv is None:
|
|
586
|
+
print(f"拒绝安装:{err}")
|
|
587
|
+
_pause()
|
|
588
|
+
return False
|
|
589
|
+
print(f"\n安装 [{item.source}] {item.title}({item.name})")
|
|
590
|
+
if item.version:
|
|
591
|
+
print(f" 版本: {item.version}")
|
|
592
|
+
if item.size_text:
|
|
593
|
+
print(f" 体积: {item.size_text}")
|
|
594
|
+
print(" 命令: " + " ".join(argv))
|
|
595
|
+
if item.installed:
|
|
596
|
+
print(" 注意:本机已装过同名包,这会变成升级/覆盖安装")
|
|
597
|
+
if not _ask_yes("执行?"):
|
|
598
|
+
return False
|
|
599
|
+
res = catalog.install(item, on_line=print, cfg=self.cfg)
|
|
600
|
+
print("✓ 完成" if res.ok else f"✗ 失败:{res.error}")
|
|
601
|
+
_pause()
|
|
602
|
+
return res.ok
|
|
603
|
+
|
|
604
|
+
if scr.run_external(go):
|
|
605
|
+
self.close_detail()
|
|
606
|
+
self.msg = f"已安装 {item.name}(按 R 重新采集以刷新列表)"
|
|
607
|
+
|
|
608
|
+
def act_download(self, scr, name, version=""):
|
|
609
|
+
def go():
|
|
610
|
+
print(f"\n下载 {name} {version or '(候选版本)'} …")
|
|
611
|
+
res = actions.download(name, version, cfg=self.cfg)
|
|
612
|
+
print(f"✓ {res.file}" if res.ok else f"✗ {res.error}")
|
|
613
|
+
_pause()
|
|
614
|
+
return res.ok
|
|
615
|
+
|
|
616
|
+
if scr.run_external(go):
|
|
617
|
+
self.msg = f"已下载到 {self.cfg.download_dir}"
|
|
618
|
+
|
|
619
|
+
def act_clean(self, scr, target):
|
|
620
|
+
def go():
|
|
621
|
+
print(f"\n→ {labels.clean_label(target.kind, target.label)}"
|
|
622
|
+
f"({target.size_text})")
|
|
623
|
+
print(" " + target.detail)
|
|
624
|
+
print(" 方式: " + (" ".join(target.argv) if target.argv
|
|
625
|
+
else "删除 " + ", ".join(target.paths)))
|
|
626
|
+
if not _ask_yes("执行?"):
|
|
627
|
+
return False
|
|
628
|
+
res = clean.delete(target, trash=self.trash, on_line=print, cfg=self.cfg)
|
|
629
|
+
print("✓ 完成" if res.ok else f"✗ 失败:{res.error}")
|
|
630
|
+
_pause()
|
|
631
|
+
return res.ok
|
|
632
|
+
|
|
633
|
+
if scr.run_external(go):
|
|
634
|
+
self.rebuild()
|
|
635
|
+
self.msg = f"已清理 {labels.clean_label(target.kind, target.label)}"
|
|
636
|
+
|
|
637
|
+
def act_clean_marked(self, scr):
|
|
638
|
+
targets = [it.data for it in self.items if it.marked and it.kind == "target"]
|
|
639
|
+
if not targets:
|
|
640
|
+
self.msg = "没有标记项(先用空格标记)"
|
|
641
|
+
return
|
|
642
|
+
|
|
643
|
+
def go():
|
|
644
|
+
print(f"\n将清理 {len(targets)} 项:")
|
|
645
|
+
for t in targets:
|
|
646
|
+
print(f" {t.size_text:>9} {labels.clean_label(t.kind, t.label)}")
|
|
647
|
+
if not _ask_yes("\n全部执行?"):
|
|
648
|
+
return 0
|
|
649
|
+
ok = 0
|
|
650
|
+
for t in targets:
|
|
651
|
+
res = clean.delete(t, trash=self.trash, on_line=print, cfg=self.cfg)
|
|
652
|
+
print(f"{'✓' if res.ok else '✗'} "
|
|
653
|
+
f"{labels.clean_label(t.kind, t.label)}: "
|
|
654
|
+
f"{res.error or t.size_text}")
|
|
655
|
+
ok += 1 if res.ok else 0
|
|
656
|
+
_pause()
|
|
657
|
+
return ok
|
|
658
|
+
|
|
659
|
+
n = scr.run_external(go)
|
|
660
|
+
if n:
|
|
661
|
+
self.rebuild()
|
|
662
|
+
self.msg = f"已清理 {n} 项"
|
|
663
|
+
|
|
664
|
+
def edit_filter(self, scr):
|
|
665
|
+
label = "搜索 apt 源: " if self.view == _SEARCH else "过滤: "
|
|
666
|
+
val = scr.prompt(label, self.filter[self.view])
|
|
667
|
+
if val is None:
|
|
668
|
+
self.msg = "已取消"
|
|
669
|
+
return
|
|
670
|
+
self.filter[self.view] = val
|
|
671
|
+
self.rebuild()
|
|
672
|
+
self.msg = (f"过滤 “{val}” → {len(self.items)} 项" if val
|
|
673
|
+
else "已清除过滤")
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _nav(app, kind, val):
|
|
677
|
+
"""列表导航。Tab 在 getch 下是 9 号字符,Shift-Tab 是 KEY_BTAB。"""
|
|
678
|
+
if kind == "char":
|
|
679
|
+
if val == "\t":
|
|
680
|
+
app.set_view((app.view + 1) % len(VIEWS))
|
|
681
|
+
return True
|
|
682
|
+
if val in "123456789"[:len(VIEWS)]:
|
|
683
|
+
app.set_view(int(val) - 1)
|
|
684
|
+
return True
|
|
685
|
+
if kind == "key" and val == curses.KEY_BTAB:
|
|
686
|
+
app.set_view((app.view - 1) % len(VIEWS))
|
|
687
|
+
return True
|
|
688
|
+
return app.lv.handle_key(kind, val)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _main(std, cfg, inv):
|
|
692
|
+
scr = tui.Screen(std)
|
|
693
|
+
app = App(cfg, inv)
|
|
694
|
+
|
|
695
|
+
while True:
|
|
696
|
+
app.draw(scr)
|
|
697
|
+
kind, val = scr.read_key()
|
|
698
|
+
|
|
699
|
+
if app.mode == "detail":
|
|
700
|
+
if tui.is_quit(kind, val):
|
|
701
|
+
app.close_detail()
|
|
702
|
+
elif kind == "char" and val == "?":
|
|
703
|
+
app.show_help()
|
|
704
|
+
elif kind == "key" and val == curses.KEY_UP:
|
|
705
|
+
app.scroll -= 1
|
|
706
|
+
elif kind == "key" and val == curses.KEY_DOWN:
|
|
707
|
+
app.scroll += 1
|
|
708
|
+
elif kind == "char" and val == "k":
|
|
709
|
+
app.scroll -= 1
|
|
710
|
+
elif kind == "char" and val == "j":
|
|
711
|
+
app.scroll += 1
|
|
712
|
+
elif kind == "char" and val == "g":
|
|
713
|
+
app.scroll = 0
|
|
714
|
+
elif kind == "char" and val == "G":
|
|
715
|
+
app.scroll = len(app.detail_lines)
|
|
716
|
+
elif kind == "char" and val == "o" and app.detail_rec \
|
|
717
|
+
and launch.plan(app.detail_rec):
|
|
718
|
+
app.act_open(app.detail_rec)
|
|
719
|
+
elif kind == "char" and val == "r" and app.detail_rec:
|
|
720
|
+
if app.detail_rec.is_loose_file:
|
|
721
|
+
app.act_delete_loose(scr, app.detail_rec)
|
|
722
|
+
elif classify.is_removable(app.detail_rec):
|
|
723
|
+
app.act_remove(scr, app.detail_rec)
|
|
724
|
+
elif kind == "char" and val == "u" and app.detail_rec \
|
|
725
|
+
and not app.detail_rec.is_loose_file \
|
|
726
|
+
and upgrade.plan(app.detail_rec):
|
|
727
|
+
app.act_upgrade(scr, app.detail_rec)
|
|
728
|
+
elif kind == "char" and val == "i" and app.detail_item:
|
|
729
|
+
app.act_install_item(scr, app.detail_item)
|
|
730
|
+
elif kind == "char" and val == "d":
|
|
731
|
+
if app.detail_item:
|
|
732
|
+
if app.detail_item.source == "apt":
|
|
733
|
+
app.act_download(scr, app.detail_item.name,
|
|
734
|
+
app.detail_item.version)
|
|
735
|
+
elif app.detail_rec and app.detail_rec.pkg_type == "deb" \
|
|
736
|
+
and not app.detail_rec.is_loose_file:
|
|
737
|
+
app.act_download(scr, app.detail_rec.name,
|
|
738
|
+
app.detail_rec.candidate)
|
|
739
|
+
continue
|
|
740
|
+
|
|
741
|
+
if tui.is_quit(kind, val):
|
|
742
|
+
return 0
|
|
743
|
+
if kind == "char" and val == "?":
|
|
744
|
+
app.show_help()
|
|
745
|
+
continue
|
|
746
|
+
if kind == "char" and val == "/":
|
|
747
|
+
app.edit_filter(scr)
|
|
748
|
+
continue
|
|
749
|
+
if kind == "char" and val == "s":
|
|
750
|
+
# 同一个键在搜索视图里切来源、在包视图里循环披露层级:
|
|
751
|
+
# 两个语义在各自视图里都用得上,且互不冲突
|
|
752
|
+
if app.view == _SEARCH:
|
|
753
|
+
app.cycle_source()
|
|
754
|
+
else:
|
|
755
|
+
app.cycle_level()
|
|
756
|
+
continue
|
|
757
|
+
if kind == "char" and val == "R":
|
|
758
|
+
app.reload(scr)
|
|
759
|
+
continue
|
|
760
|
+
cur = app.current()
|
|
761
|
+
if kind == "char" and val == "T" and (
|
|
762
|
+
app.view in (_CLEAN, _LOOSE)
|
|
763
|
+
or (cur and cur.kind == "pkg" and cur.data.is_loose_file)):
|
|
764
|
+
app.trash = not app.trash
|
|
765
|
+
app.msg = ("删除方式:移入回收站(可还原)" if app.trash
|
|
766
|
+
else "删除方式:真删")
|
|
767
|
+
continue
|
|
768
|
+
if kind == "char" and val == "t" and app.view in (_LIST, _UPGRADABLE,
|
|
769
|
+
_LOOSE):
|
|
770
|
+
app.cycle_type()
|
|
771
|
+
continue
|
|
772
|
+
if app.view == _CLEAN and kind == "char" and val == " ":
|
|
773
|
+
it = app.current()
|
|
774
|
+
if it and it.kind == "target":
|
|
775
|
+
it.marked = not it.marked
|
|
776
|
+
app.lv.move(1)
|
|
777
|
+
continue
|
|
778
|
+
if app.view == _CLEAN and kind == "char" and val == "x":
|
|
779
|
+
app.act_clean_marked(scr)
|
|
780
|
+
continue
|
|
781
|
+
if tui.is_enter(kind, val):
|
|
782
|
+
it = app.current()
|
|
783
|
+
if it and it.kind == "target":
|
|
784
|
+
app.act_clean(scr, it.data)
|
|
785
|
+
elif it:
|
|
786
|
+
app.open_detail()
|
|
787
|
+
continue
|
|
788
|
+
if kind == "char" and val == "o":
|
|
789
|
+
it = app.current()
|
|
790
|
+
if it and it.kind == "pkg" and launch.plan(it.data):
|
|
791
|
+
app.act_open(it.data)
|
|
792
|
+
continue
|
|
793
|
+
if kind == "char" and val == "r":
|
|
794
|
+
it = app.current()
|
|
795
|
+
if it and it.kind == "pkg":
|
|
796
|
+
if it.data.is_loose_file:
|
|
797
|
+
app.act_delete_loose(scr, it.data)
|
|
798
|
+
elif classify.is_removable(it.data):
|
|
799
|
+
app.act_remove(scr, it.data)
|
|
800
|
+
continue
|
|
801
|
+
if kind == "char" and val == "u":
|
|
802
|
+
it = app.current()
|
|
803
|
+
if it and it.kind == "pkg" and not it.data.is_loose_file \
|
|
804
|
+
and upgrade.plan(it.data):
|
|
805
|
+
app.act_upgrade(scr, it.data)
|
|
806
|
+
continue
|
|
807
|
+
if kind == "char" and val == "i":
|
|
808
|
+
it = app.current()
|
|
809
|
+
if it and it.kind == "repo":
|
|
810
|
+
app.act_install_item(scr, it.data)
|
|
811
|
+
continue
|
|
812
|
+
if kind == "char" and val == "d":
|
|
813
|
+
it = app.current()
|
|
814
|
+
if it and it.kind == "repo":
|
|
815
|
+
if it.data.source == "apt":
|
|
816
|
+
app.act_download(scr, it.data.name, it.data.version)
|
|
817
|
+
elif it and it.kind == "pkg" and it.data.pkg_type == "deb" \
|
|
818
|
+
and not it.data.is_loose_file:
|
|
819
|
+
app.act_download(scr, it.data.name, it.data.candidate)
|
|
820
|
+
continue
|
|
821
|
+
if _nav(app, kind, val):
|
|
822
|
+
continue
|
|
823
|
+
app.msg = f"未识别的按键 {val!r}(按 ? 看帮助)"
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def run(cfg=CFG, inv=None):
|
|
827
|
+
"""裸跑 pkgtool 的入口 → 退出码。非 tty 返回 None,由 CLI 降级为提示。"""
|
|
828
|
+
if not tui.is_tty():
|
|
829
|
+
return None
|
|
830
|
+
if inv is None:
|
|
831
|
+
inv = inventory.collect(
|
|
832
|
+
cfg, on_backend=lambda t, n, e, err: print(
|
|
833
|
+
f"[{t}] {n} 个 ({e:.1f}s)" + (f" 失败: {err}" if err else ""),
|
|
834
|
+
file=sys.stderr))
|
|
835
|
+
try:
|
|
836
|
+
locale.setlocale(locale.LC_ALL, "")
|
|
837
|
+
rc = curses.wrapper(_main, cfg, inv)
|
|
838
|
+
return 0 if rc is None else rc
|
|
839
|
+
except curses.error as e:
|
|
840
|
+
print(f"无法进入交互界面(终端不支持 curses):{e}", file=sys.stderr)
|
|
841
|
+
return 1
|