Skip to content

Commit 34b2e69

Browse files
committed
version 0.5.0
1 parent f2b7ae9 commit 34b2e69

10 files changed

Lines changed: 349 additions & 17 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -509,7 +509,7 @@ A release is built from a tag:
509509

510510
```bash
511511
# bump the version in package.json, src-tauri/Cargo.toml and tauri.conf.json together
512-
git tag v0.4.1 && git push origin v0.4.1
512+
git tag v0.5.0 && git push origin v0.5.0
513513
```
514514

515515
The workflow builds a universal `.dmg` for macOS (arm64 + x86_64 in one file),

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "pyshell",
3-
"version": "0.4.1",
3+
"version": "0.5.0",
44
"private": true,
55
"description": "GUI for running Python scripts with isolated venvs",
66
"author": "Dmytro Lobov",

src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "pyshell"
3-
version = "0.4.1"
3+
version = "0.5.0"
44
description = "GUI for running Python scripts with isolated venvs"
55
authors = ["Dmytro Lobov"]
66
edition = "2021"

src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://schema.tauri.app/config/2",
33
"productName": "PyShell",
4-
"version": "0.4.1",
4+
"version": "0.5.0",
55
"identifier": "com.pyshell.app",
66
"build": {
77
"frontendDist": "../dist",

src/components/StoreDialog.tsx

Lines changed: 122 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,20 @@ import { listen } from "@tauri-apps/api/event";
44
import { ipc } from "../lib/ipc";
55
import type { RepoInstallResult, RepoScript, ScriptEntry } from "../types/schema";
66
import { ScriptIcon } from "../lib/script-icon";
7-
import { filterCatalog, formatBytes, groupCatalog, hasUpdate, installStateOf } from "../lib/store-utils";
7+
import {
8+
ALL_CATEGORIES,
9+
catalogCategories,
10+
filterByCategory,
11+
filterByState,
12+
filterCatalog,
13+
formatBytes,
14+
groupCatalog,
15+
hasUpdate,
16+
installStateOf,
17+
storeCounts,
18+
STORE_FILTERS,
19+
} from "../lib/store-utils";
20+
import type { StoreFilter } from "../lib/store-utils";
821
import { useToast } from "./Toast";
922
import { Modal } from "./Modal";
1023
import { useI18n } from "../lib/i18n";
@@ -92,6 +105,8 @@ export function StoreDialog({ scripts, runningScripts, onInstalled, onRemoved, o
92105
const [error, setError] = useState<string | null>(null);
93106
const [refreshing, setRefreshing] = useState(false);
94107
const [query, setQuery] = useState("");
108+
const [filter, setFilter] = useState<StoreFilter>("all");
109+
const [category, setCategory] = useState<string>(ALL_CATEGORIES);
95110
const [dest, setDest] = useState<string>(loadDest);
96111
const [installing, setInstalling] = useState<InstallState | null>(null);
97112
const [confirm, setConfirm] = useState<ConfirmAction | null>(null);
@@ -253,9 +268,51 @@ export function StoreDialog({ scripts, runningScripts, onInstalled, onRemoved, o
253268
for (const e of entries ?? []) m.set(e.id, e.name);
254269
return m;
255270
}, [entries]);
256-
const visible = filterCatalog(entries ?? [], query);
271+
const categories = useMemo(() => catalogCategories(entries ?? []), [entries]);
272+
// A Refresh can drop the category the user had picked (its last script left
273+
// the repo). Deriving the effective key instead of storing it means the
274+
// selection heals itself rather than pointing the list at an empty section.
275+
const categoryKey = categories.some((c) => c.key === category) ? category : ALL_CATEGORIES;
276+
const inCategory = useMemo(
277+
() => filterByCategory(entries ?? [], categoryKey),
278+
[entries, categoryKey],
279+
);
280+
281+
// The pill badges count within the chosen category, but ignore the search:
282+
// the category is the scope the user set and expects the counts to follow,
283+
// while a badge moving with every keystroke would only repeat how many rows
284+
// the search found — which the list itself already shows.
285+
const counts = useMemo(() => storeCounts(inCategory, scripts), [inCategory, scripts]);
286+
const visible = filterCatalog(filterByState(inCategory, scripts, filter), query);
257287
const sections = useMemo(() => groupCatalog(visible), [visible]);
258288

289+
const filterLabels: Record<StoreFilter, string> = {
290+
all: t("All"),
291+
installed: t("Installed"),
292+
updates: t("Updates"),
293+
};
294+
const filterTitles: Record<StoreFilter, string> = {
295+
all: t("All scripts, installed or not"),
296+
installed: t("Scripts you have already imported"),
297+
updates: t("Installed scripts the repo carries a newer version of"),
298+
};
299+
300+
// Why the list is empty, in the words of whatever emptied it: an empty repo,
301+
// a search that found nothing, or a filter with nothing in it. "Nothing
302+
// matches" under an empty search box would be a riddle.
303+
const scoped = categoryKey !== ALL_CATEGORIES;
304+
const emptyMessage = (entries?.length ?? 0) === 0
305+
? t("No scripts found in the repo.")
306+
: query.trim()
307+
? t("Nothing matches \"{q}\".", { q: query })
308+
: filter === "installed"
309+
? scoped
310+
? t("Nothing from this category is installed yet.")
311+
: t("No store scripts are installed yet.")
312+
: scoped
313+
? t("Everything you installed from this category is up to date.")
314+
: t("Everything you installed from the store is up to date.");
315+
259316
const confirmTitle = confirm
260317
? confirm.kind === "update"
261318
? t("Update {name}?", { name: confirm.entry.name })
@@ -315,7 +372,7 @@ export function StoreDialog({ scripts, runningScripts, onInstalled, onRemoved, o
315372
</button>
316373
</div>
317374

318-
<div class="border-b border-line px-5 py-2.5">
375+
<div class="space-y-2 border-b border-line px-5 py-2.5">
319376
<div class="relative">
320377
<SearchIcon
321378
size={13}
@@ -339,6 +396,67 @@ export function StoreDialog({ scripts, runningScripts, onInstalled, onRemoved, o
339396
</button>
340397
)}
341398
</div>
399+
400+
{/* Install state on the left, category scope on the right — two axes
401+
that combine, both narrowing the one list below. Toggle buttons
402+
rather than a `tablist`: they filter a list instead of swapping
403+
panels, and each stays reachable by Tab without arrow-key handling. */}
404+
<div class="flex items-center gap-1">
405+
<div class="flex items-center gap-1" role="group" aria-label={t("Filter by install state")}>
406+
{STORE_FILTERS.map((f) => {
407+
const active = filter === f;
408+
// An idle Updates pill with something in it carries the accent,
409+
// the same colour as the dot on "+ Store" that sent the user here.
410+
const pending = f === "updates" && counts.updates > 0 && !active;
411+
return (
412+
<button
413+
key={f}
414+
type="button"
415+
aria-pressed={active}
416+
title={filterTitles[f]}
417+
class={`pill ${
418+
active
419+
? "bg-accent/15 text-accent"
420+
: pending
421+
? "text-accent hover:bg-accent/10"
422+
: "text-subtle hover:text-fg"
423+
}`}
424+
onClick={() => setFilter(f)}
425+
>
426+
{filterLabels[f]}
427+
{entries !== null && (
428+
<span class={`tabular-nums ${active || pending ? "" : "text-subtle/70"}`}>
429+
{counts[f]}
430+
</span>
431+
)}
432+
</button>
433+
);
434+
})}
435+
</div>
436+
437+
{/* Category scope. A `select` rather than a second row of pills: six
438+
categories fit either way, but the list comes from the repo and
439+
grows without an app release — a dozen pills would wrap, while
440+
this stays one line. Counts sit in the labels, the way the log
441+
view's stream filter carries them. */}
442+
{categories.length > 1 && (
443+
<select
444+
class="form-input ml-auto w-auto py-0.5 pl-2 pr-7 text-2xs"
445+
value={categoryKey}
446+
aria-label={t("Filter by category")}
447+
onChange={(e) => setCategory(e.currentTarget.value)}
448+
>
449+
<option value={ALL_CATEGORIES}>
450+
{t("All categories")} ({entries?.length ?? 0})
451+
</option>
452+
{categories.map((c) => (
453+
<option key={c.key} value={c.key}>
454+
{c.label} ({c.count})
455+
</option>
456+
))}
457+
</select>
458+
)}
459+
</div>
342460
</div>
343461

344462
<div class="flex-1 space-y-1.5 overflow-y-auto px-4 py-3">
@@ -363,9 +481,7 @@ export function StoreDialog({ scripts, runningScripts, onInstalled, onRemoved, o
363481

364482
{!error && entries !== null && visible.length === 0 && (
365483
<p class="px-1 py-6 text-center text-2xs text-subtle">
366-
{entries.length === 0
367-
? t("No scripts found in the repo.")
368-
: t("Nothing matches \"{q}\".", { q: query })}
484+
{emptyMessage}
369485
</p>
370486
)}
371487

src/lib/i18n.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,20 @@ const ua: Dictionary = {
387387
"Loading the catalog from GitHub…": "Завантаження каталогу з GitHub…",
388388
"No scripts found in the repo.": "У репозиторії немає скриптів.",
389389
"Nothing matches \"{q}\".": "Нічого не знайдено за запитом «{q}».",
390+
"Updates": "Оновлення",
391+
"Filter by install state": "Фільтр за станом встановлення",
392+
"Filter by category": "Фільтр за категорією",
393+
"All categories": "Усі категорії",
394+
"Nothing from this category is installed yet.": "З цієї категорії ще нічого не встановлено.",
395+
"Everything you installed from this category is up to date.":
396+
"Усе встановлене з цієї категорії має актуальну версію.",
397+
"All scripts, installed or not": "Усі скрипти — встановлені й ні",
398+
"Scripts you have already imported": "Скрипти, які ви вже імпортували",
399+
"Installed scripts the repo carries a newer version of":
400+
"Встановлені скрипти, для яких у репозиторії є новіша версія",
401+
"No store scripts are installed yet.": "Ще не встановлено жодного скрипта зі Store.",
402+
"Everything you installed from the store is up to date.":
403+
"Усе встановлене зі Store має актуальну версію.",
390404
"Refresh catalog": "Оновити каталог",
391405
"Re-fetch the catalog from GitHub (spends one API request)": "Повторно завантажити каталог з GitHub (витрачає один API-запит)",
392406
"needs": "потребує",

src/lib/store-utils.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
import { describe, expect, it } from "vitest";
22
import {
3+
ALL_CATEGORIES,
4+
catalogCategories,
35
countUpdates,
6+
filterByCategory,
7+
filterByState,
48
filterCatalog,
59
formatBytes,
610
groupCatalog,
711
hasUpdate,
812
installStateOf,
913
installedIds,
14+
isUpdatable,
15+
storeCounts,
1016
} from "./store-utils";
1117
import type { RepoScript, ScriptEntry } from "../types/schema";
1218

@@ -177,3 +183,111 @@ describe("formatBytes", () => {
177183
expect(formatBytes(1280)).toBe("1 KB");
178184
});
179185
});
186+
187+
describe("filterByState", () => {
188+
const entries = [
189+
entry({ dir: "a", id: "a", version: "2", installed_version: "1" }), // update
190+
entry({ dir: "b", id: "b", version: "1", installed_version: "1" }), // current
191+
entry({ dir: "c", id: "c" }), // not installed
192+
entry({ dir: "d", id: "d", version: "2", installed_version: "1" }), // gone
193+
];
194+
const scripts = [script("a"), script("b"), script("d", false)];
195+
196+
it("passes everything through for \"all\"", () => {
197+
expect(filterByState(entries, scripts, "all")).toHaveLength(4);
198+
});
199+
200+
it("keeps every imported script, unreachable ones included", () => {
201+
expect(filterByState(entries, scripts, "installed").map((e) => e.dir)).toEqual(["a", "b", "d"]);
202+
});
203+
204+
it("keeps only rows the Update button would offer", () => {
205+
// "d" has a newer version but its folder is gone — the row shows Missing
206+
// folder instead of Update, so the filter must not promise one either.
207+
expect(filterByState(entries, scripts, "updates").map((e) => e.dir)).toEqual(["a"]);
208+
});
209+
210+
it("agrees with isUpdatable and countUpdates", () => {
211+
expect(filterByState(entries, scripts, "updates").every((e) => isUpdatable(e, scripts))).toBe(true);
212+
expect(filterByState(entries, scripts, "updates")).toHaveLength(countUpdates(entries, scripts));
213+
});
214+
215+
it("is empty when nothing is installed", () => {
216+
expect(filterByState(entries, [], "installed")).toEqual([]);
217+
expect(filterByState(entries, [], "updates")).toEqual([]);
218+
});
219+
});
220+
221+
describe("storeCounts", () => {
222+
it("counts each filter the way the filter itself does", () => {
223+
const entries = [
224+
entry({ dir: "a", id: "a", version: "2", installed_version: "1" }),
225+
entry({ dir: "b", id: "b", version: "1", installed_version: "1" }),
226+
entry({ dir: "c", id: "c" }),
227+
entry({ dir: "d", id: "d", version: "2", installed_version: "1" }),
228+
];
229+
const scripts = [script("a"), script("b"), script("d", false)];
230+
expect(storeCounts(entries, scripts)).toEqual({ all: 4, installed: 3, updates: 1 });
231+
for (const f of ["all", "installed", "updates"] as const) {
232+
expect(filterByState(entries, scripts, f)).toHaveLength(storeCounts(entries, scripts)[f]);
233+
}
234+
});
235+
236+
it("is all zeroes for an empty catalog", () => {
237+
expect(storeCounts([], [script("a")])).toEqual({ all: 0, installed: 0, updates: 0 });
238+
});
239+
});
240+
241+
describe("catalogCategories", () => {
242+
const entries = [
243+
entry({ dir: "a", category: "SEO" }),
244+
entry({ dir: "b", category: "Recon" }),
245+
entry({ dir: "c", category: null }),
246+
entry({ dir: "d", category: "SEO" }),
247+
];
248+
249+
it("lists the categories in the order the list renders them, with counts", () => {
250+
expect(catalogCategories(entries)).toEqual([
251+
{ key: "Recon", label: "Recon", count: 1 },
252+
{ key: "SEO", label: "SEO", count: 2 },
253+
{ key: "\u0000other", label: "Other", count: 1 },
254+
]);
255+
});
256+
257+
it("agrees with the sections the list groups into", () => {
258+
const sections = groupCatalog(entries);
259+
expect(catalogCategories(entries).map((c) => c.key)).toEqual(sections.map((s) => s.key));
260+
expect(catalogCategories(entries).map((c) => c.count)).toEqual(
261+
sections.map((s) => s.items.length),
262+
);
263+
});
264+
265+
it("is empty for an empty catalog", () => {
266+
expect(catalogCategories([])).toEqual([]);
267+
});
268+
});
269+
270+
describe("filterByCategory", () => {
271+
const entries = [
272+
entry({ dir: "a", category: "SEO" }),
273+
entry({ dir: "b", category: "Recon" }),
274+
entry({ dir: "c", category: null }),
275+
entry({ dir: "d", category: "Other" }),
276+
];
277+
278+
it("passes everything through for the all-categories key", () => {
279+
expect(filterByCategory(entries, ALL_CATEGORIES)).toHaveLength(4);
280+
});
281+
282+
it("keeps only the chosen category", () => {
283+
expect(filterByCategory(entries, "SEO").map((e) => e.dir)).toEqual(["a"]);
284+
});
285+
286+
it("merges uncategorised scripts into a declared \"Other\", like the sections do", () => {
287+
expect(filterByCategory(entries, "Other").map((e) => e.dir)).toEqual(["d", "c"]);
288+
});
289+
290+
it("yields nothing for a category that left the catalog", () => {
291+
expect(filterByCategory(entries, "Gone")).toEqual([]);
292+
});
293+
});

0 commit comments

Comments
 (0)