Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "radish-security-notes",
"version": "1.0.0",
"version": "1.1.0",
"description": "本地加密的桌面密码与笔记保险库 (Electron, React, TypeScript)",
"main": "./out/main/index.js",
"author": "auYe",
Expand Down
19 changes: 19 additions & 0 deletions src/renderer/src/components/ui/skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { cn } from "cn";

/**
* 骨架占位块, 内容读取期间按内容的大致形状占位, 底色缓慢明暗变化.
* @param root0 组件属性, 含 className 与块级元素原生属性.
* @returns 占位块元素.
*/
export function Skeleton({
className,
...props
}: React.ComponentProps<"div">): React.JSX.Element {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
);
}
7 changes: 7 additions & 0 deletions src/renderer/src/components/ui/state-motion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,10 @@ export const MARK_TRANSITION =
*/
export const FADE_IN_MOTION =
"animate-in fade-in-0 duration-(--motion-fast) ease-(--motion-ease)";

/**
* 基础档内容入场: 元素挂载时淡入并自下方上移一小段到位, 用于条目详情这类整块换入的主要内容,
* 比快档淡入更明显. 消失时直接卸载, 不做动画.
*/
export const CONTENT_ENTER_MOTION =
"animate-in fade-in-0 slide-in-from-bottom-2 duration-(--motion-base) ease-(--motion-ease)";
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export function AttachmentDropZone(
aria-label={props.label}
className={cn(
FAST_STATE_TRANSITION,
"flex max-w-xl flex-col gap-3 rounded-lg border border-dashed p-3",
"flex flex-col gap-3 rounded-lg border border-dashed p-3",
isDragging ? "border-primary bg-muted" : "border-border",
)}
{...handlers}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,36 @@ describe("EntryDetailPane 展示", () => {
await user.click(screen.getByRole("button", { name: "隐藏 密码" }));
expect(screen.queryByText("forum-password")).toBeNull();
});
});

describe("EntryDetailPane 读取中与读取失败", () => {
it("读取详情期间显示骨架, 读取完成后换成详情", async () => {
let releaseDetail: () => void = () => undefined;
const environment = await createEntryTestEnvironment({
entries: TEST_ENTRIES,
entryBridgeOverrides: {
get: async () => {
await new Promise<void>((resolve) => {
releaseDetail = resolve;
});
return { ok: true, value: FORUM_ENTRY };
},
},
});
render(<EntryDetailPane />, { wrapper: environment.Providers });

const pending = environment.entryStore.getState().select("forum");

expect(
await screen.findByRole("status", { name: "正在读取条目详情" }),
).toBeDefined();
releaseDetail();
await pending;
expect(await screen.findByRole("heading", { name: "论坛" })).toBeDefined();
expect(
screen.queryByRole("status", { name: "正在读取条目详情" }),
).toBeNull();
});

it("读取详情失败时说明原因", async () => {
await renderPane("forum", {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { EntryDetail } from "@shared/entries/entry-types";
import type { EntrySelection } from "@renderer/stores/entry-state";
import { useEntryStore } from "@renderer/stores/use-entry-store";

import { EntryDetailSkeleton } from "./entry-detail-skeleton";
import { EntryDetailView } from "./entry-detail-view";

/**
Expand Down Expand Up @@ -45,25 +46,25 @@ function CenteredMessage(props: CenteredMessageProps): React.JSX.Element {
}

/**
* 按选中状态选出详情窗格的内容: 没有选中时提示选择, 读取中不显示内容, 读取失败时说明原因,
* 按选中状态选出详情窗格的内容: 没有选中时提示选择, 读取中显示骨架, 读取失败时说明原因,
* 读取完成后显示详情. 详情视图以条目编号与编辑次数作 key, 切换条目或保存编辑后重新挂载.
* @param selection 当前选中状态.
* @param revision 条目被编辑保存的次数.
* @param translate 翻译函数.
* @param renderers 生成标题行右侧操作与备注之后附件区的函数.
* @returns 窗格内容, 读取中时为 null.
* @returns 窗格内容.
*/
function renderSelection(
selection: EntrySelection,
revision: number,
translate: TFunction,
renderers: Pick<EntryDetailPaneProps, "renderActions" | "renderAttachments">,
): React.JSX.Element | null {
): React.JSX.Element {
switch (selection.status) {
case "none":
return <CenteredMessage message={translate("entryDetailPane.empty")} />;
case "loading":
return null;
return <EntryDetailSkeleton />;
case "failed":
return (
<CenteredMessage message={translate("entryDetailPane.loadFailed")} />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { useTranslation } from "react-i18next";

import { Skeleton } from "@renderer/components/ui/skeleton";
import { FADE_IN_MOTION } from "@renderer/components/ui/state-motion";
import { cn } from "@renderer/lib/class-names";

/**
* 骨架里各字段值占位块的宽度类名, 长短错开, 一项对应一行字段.
*/
const FIELD_VALUE_WIDTH_CLASS_NAMES = ["w-56", "w-32", "w-64", "w-40"] as const;

/**
* 条目详情读取期间的骨架: 按详情的版式摆出类型, 名称, 文件夹与几行字段的占位块, 间距与详情视图
* 一致, 读取完成后内容原位替换. 占位块本身对读屏软件隐藏, 整体以状态区域通知正在读取.
* @returns 骨架元素.
*/
export function EntryDetailSkeleton(): React.JSX.Element {
const { t } = useTranslation();
return (
<div
role="status"
aria-label={t("entryDetailPane.loading")}
className={cn(FADE_IN_MOTION, "flex flex-col gap-6 p-8")}
>
<div aria-hidden="true" className="flex flex-col gap-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-7 w-48" />
<Skeleton className="h-4 w-24" />
</div>
<div aria-hidden="true" className="flex flex-col gap-4">
{FIELD_VALUE_WIDTH_CLASS_NAMES.map((widthClassName) => (
<div key={widthClassName} className="flex flex-col gap-2">
<Skeleton className="h-4 w-12" />
<Skeleton className={cn("h-6", widthClassName)} />
</div>
))}
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { act, render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";

import { FADE_IN_MOTION } from "@renderer/components/ui/state-motion";
import { CONTENT_ENTER_MOTION } from "@renderer/components/ui/state-motion";
import { TEST_ENTRIES } from "@renderer/testing/entry-fixtures";
import {
createEntryTestEnvironment,
Expand Down Expand Up @@ -51,13 +51,13 @@ function detailRootOf(container: HTMLElement): Element | null {
}

describe("条目详情内容的切换动效", () => {
it("详情内容挂载时快档淡入", async () => {
it("详情内容挂载时淡入并上移到位", async () => {
const { container } = await renderPane("forum");

expectMotionClasses(detailRootOf(container), FADE_IN_MOTION);
expectMotionClasses(detailRootOf(container), CONTENT_ENTER_MOTION);
});

it("切换到另一个条目时换成新的详情元素, 新元素同样淡入", async () => {
it("切换到另一个条目时换成新的详情元素, 新元素同样入场", async () => {
const { environment, container } = await renderPane("forum");
const previousRoot = detailRootOf(container);

Expand All @@ -66,7 +66,7 @@ describe("条目详情内容的切换动效", () => {
await screen.findByRole("heading", { name: "银行" });
const nextRoot = detailRootOf(container);
expect(nextRoot).not.toBe(previousRoot);
expectMotionClasses(nextRoot, FADE_IN_MOTION);
expectMotionClasses(nextRoot, CONTENT_ENTER_MOTION);
});

it("没有选中条目时的提示文字不淡入", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import { NOTES_FIELD_KEY, type EntryDetail } from "@shared/entries/entry-types";

import { entryFieldName } from "@renderer/components/entry-type-naming";
import { FADE_IN_MOTION } from "@renderer/components/ui/state-motion";
import { CONTENT_ENTER_MOTION } from "@renderer/components/ui/state-motion";
import { cn } from "@renderer/lib/class-names";
import { useEntryStore } from "@renderer/stores/use-entry-store";
import { useEntryTypeCatalog } from "@renderer/stores/use-entry-type-catalog";
Expand Down Expand Up @@ -39,7 +39,8 @@ interface EntryDetailViewProps {
* 类型的字段, 带 TOTP 时的验证码与 TOTP 密钥, 自定义字段与备注 (按备注格式呈现), 最后是调用方给出的附件区, 敏感
* 字段默认遮罩, 每项带复制按钮. 复制由主进程写入剪贴板. 条目的类型 (预设或自定义) 经类型目录取得,
* 目录里没有这个类型时不渲染. 调用方用条目编号与编辑次数作 key, 切换条目或保存编辑后遮罩字段
* 的显示状态随之恢复为遮罩, 验证码与已显示的密钥随组件卸载而丢弃. 每次挂载时内容快档淡入.
* 的显示状态随之恢复为遮罩, 验证码与已显示的密钥随组件卸载而丢弃. 每次挂载时内容淡入并自下方
* 上移到位.
* @param props 组件属性.
* @returns 详情视图元素, 条目的类型不在目录里时为 null.
*/
Expand All @@ -55,9 +56,9 @@ export function EntryDetailView(
return null;
}
return (
<div className={cn(FADE_IN_MOTION, "flex flex-col gap-6 p-8")}>
<div className={cn(CONTENT_ENTER_MOTION, "flex flex-col gap-6 p-8")}>
<EntryDetailHeader detail={detail} type={type} actions={props.actions} />
<dl className="flex max-w-xl flex-col gap-4">
<dl className="flex flex-col gap-4">
{type.fields.map((field) => (
<DetailValueRow
key={field.key}
Expand Down
21 changes: 16 additions & 5 deletions src/renderer/src/features/entry-list-pane/entry-list-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,24 +14,35 @@ import { intentOfRowClick } from "./entry-row-click";
import { rowCheckboxLabelledBy } from "./entry-row-ids";

/**
* 列表项外层的类名. 类名在模块加载时合并好, 渲染时直接取用: 条目很多时全选会让每个列表项重新
* 列表项外层共用的类名. 类名在模块加载时合并好, 渲染时直接取用: 条目很多时全选会让每个列表项重新
* 渲染, 每次都合并类名开销不小. 选中竖条与底色的出现和消失取快档状态过渡.
*/
const ROW_CLASS_NAME = cn(
const BASE_ROW_CLASS_NAME = cn(
FAST_STATE_TRANSITION,
"flex h-(--list-row-height) items-center border-s-2 border-s-transparent",
);

/**
* 未选中的列表项外层的类名. 鼠标悬停时整行 (含勾选框) 显示底色.
*/
const ROW_CLASS_NAME = cn(
BASE_ROW_CLASS_NAME,
"hover:bg-muted dark:hover:bg-muted/50",
);

/**
* 选中查看详情的列表项外层的类名.
*/
const SELECTED_ROW_CLASS_NAME = cn(ROW_CLASS_NAME, "border-s-brand bg-muted");
const SELECTED_ROW_CLASS_NAME = cn(
BASE_ROW_CLASS_NAME,
"border-s-brand bg-muted",
);

/**
* 列表项按钮的类名.
* 列表项按钮的类名. 悬停底色由外层整行给出, 按钮自身不再显示.
*/
const BUTTON_CLASS_NAME =
"h-full min-w-0 flex-1 flex-col items-start justify-center gap-0.5 rounded-none border-0 ps-2 pe-4 text-start";
"h-full min-w-0 flex-1 flex-col items-start justify-center gap-0.5 rounded-none border-0 ps-2 pe-4 text-start hover:bg-transparent dark:hover:bg-transparent";

/**
* 正被拖拽的列表项按钮的类名.
Expand Down
18 changes: 18 additions & 0 deletions src/renderer/src/stores/entry-store-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,24 @@ export async function selectEntry(
});
}

/**
* 响应用户点选条目: 点的条目与当前选中的不同才读取详情; 点的正是已读取完成或正在读取的条目时
* 什么都不做, 详情不重新载入, 已显示的密码与验证码保持原样.
* @param access store 动作能用到的东西.
* @param id 用户点选的条目编号.
* @returns 读取完成后兑现, 不需要读取时立即兑现.
*/
export async function selectEntryIfChanged(
access: EntryStoreAccess,
id: string,
): Promise<void> {
const { selection } = access.get();
if (selection.status !== "failed" && selectedIdOf(selection) === id) {
return;
}
await selectEntry(access, id);
}

/**
* 新建一个条目. 成功后条目放到 store 数组最前 (显示位置由名称排序规则决定), 被选中并展示详情, 搜索关键字清空, 入口跟随条目 (新条目
* 不属于当前入口时切到它所属的文件夹), 让新条目一定出现在列表里.
Expand Down
26 changes: 26 additions & 0 deletions src/renderer/src/stores/entry-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,32 @@ describe("条目 store 选中", () => {
id: "missing",
});
});

it("再次选中已经选中的条目时不重新读取, 详情保持原样", async () => {
const bridge = createFakeEntryBridge([FORUM, BANK]);
const get = vi.fn(bridge.get);
bridge.get = get;
const store = createEntryStore({ bridge });
await store.getState().select("forum");
const selection = store.getState().selection;

await store.getState().select("forum");

expect(get).toHaveBeenCalledTimes(1);
expect(store.getState().selection).toBe(selection);
});

it("上一次读取失败的条目再次选中时重新读取", async () => {
const bridge = createFakeEntryBridge();
const get = vi.fn(bridge.get);
bridge.get = get;
const store = createEntryStore({ bridge });
await store.getState().select("missing");

await store.getState().select("missing");

expect(get).toHaveBeenCalledTimes(2);
});
});

describe("条目 store 选中竞争", () => {
Expand Down
8 changes: 4 additions & 4 deletions src/renderer/src/stores/entry-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
copyEntryField,
createEntry,
loadEntries,
selectEntry,
selectEntryIfChanged,
} from "./entry-store-actions";
import {
applyBatchFolder,
Expand Down Expand Up @@ -43,9 +43,9 @@ export interface EntryActions {
*/
load: () => Promise<void>;
/**
* 选中一个条目并读取它的详情.
* 选中一个条目并读取它的详情. 条目已经是选中的 (读取完成或正在读取) 时不重新读取.
* @param id 条目编号.
* @returns 详情读取完成后兑现.
* @returns 详情读取完成后兑现, 不需要读取时立即兑现.
*/
select: (id: string) => Promise<void>;
/**
Expand Down Expand Up @@ -187,7 +187,7 @@ export function createEntryStore(
return {
...INITIAL_ENTRY_STATE,
load: () => loadEntries(access),
select: (id) => selectEntry(access, id),
select: (id) => selectEntryIfChanged(access, id),
setQuery: (query) => applyQuery(access, query),
search: () => runEntrySearch(access),
create: (input) => createEntry(access, input),
Expand Down
6 changes: 5 additions & 1 deletion src/renderer/src/styles/app-motion-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ const APP_COMPONENT_COVERAGE: readonly (readonly [
["components/totp-code-button.tsx", ["FADE_IN_MOTION"]],
["components/copy-button.tsx", ["FADE_IN_MOTION"]],
["features/entry-batch-bar/batch-bar-actions.tsx", ["FADE_IN_MOTION"]],
["features/entry-detail-pane/entry-detail-view.tsx", ["FADE_IN_MOTION"]],
[
"features/entry-detail-pane/entry-detail-view.tsx",
["CONTENT_ENTER_MOTION"],
],
["features/entry-detail-pane/entry-detail-skeleton.tsx", ["FADE_IN_MOTION"]],
];

/**
Expand Down
1 change: 1 addition & 0 deletions src/shared/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@
"entryDetailPane": {
"heading": "Entry details",
"empty": "Select an entry to see its details",
"loading": "Loading entry details",
"loadFailed": "Unable to load the details of this entry."
},
"entryDetail": {
Expand Down
Loading
Loading