feat(web): палитра команд Ctrl/Cmd+K и подтверждение выхода с несохранёнными правками

Два требования desktop-клиента (docs/client-tauri.md §5), которые живут в
веб-слое — том же, что и в браузере:

- **Палитра команд.** Ctrl/Cmd+K (на документе или сообщением `openPalette` от
  обёртки) открывает быстрый переход: серверы (в первую доступную комнату),
  комнаты выбранного сервера с группировкой по категориям, личные беседы из уже
  загруженного снапшота, действия (настройки профиля/безопасности/внешнего вида,
  настройки сервера, создание сервера, друзья, микрофон и «не слышу» при
  активной голосовой сессии). Новых запросов палитра не делает; поиск по
  подстроке без учёта регистра, 8 пунктов на раздел, стрелки со
  `scrollIntoView`, `role=listbox/option` и `aria-activedescendant`;

- **Подтверждение выхода.** Формы с локальным черновиком регистрируются в общем
  реестре (`stores/unsaved.ts` + хук `useUnsavedChanges`): платформа получает
  один `setDirty` на переход «есть правки / нет правок», а на сообщение обёртки
  `closeRequested` клиент отвечает тряской контента и диалогом «Сохранить /
  Выйти без сохранения». При `prefers-reduced-motion: reduce` тряску заменяет
  подсветка рамки; в браузере вместо сообщения обёртки работает штатный
  `beforeunload`. Хук добавлен в формы с явной кнопкой «Сохранить»:
  профиль, общие настройки сервера, ник, эмодзи, звуки, оформление, вебхуки,
  лимиты инстанса, переименование сервера (автосохраняемые переключатели
  намеренно не помечаются);

- Deep link `glchat://dm/<channelId>` (личная беседа без сервера) — его
  присылает обёртка при клике по уведомлению о личном сообщении.

Тесты: 525 passed (501 база + 24 новых) — палитра, реестр правок, диалог
закрытия, `beforeunload`, разбор ссылок; `npm run check` и `npm run lint` зелёные.
This commit is contained in:
2026-09-22 23:17:32 +03:00
parent 72c5189e8f
commit 085454d555
26 changed files with 1818 additions and 34 deletions
+105
View File
@@ -0,0 +1,105 @@
import { create } from 'zustand';
import { platform } from '@/lib/platform';
/**
* Единый реестр несохранённых изменений (docs/client-tauri.md §5).
*
* Формы с локальным черновиком регистрируют здесь запись, пока в них есть
* правки. Реестр нужен по двум причинам: платформа должна знать, перехватывать
* ли закрытие окна (один флаг на всё приложение), а диалог закрытия — уметь
* сохранить все формы по очереди, не зная о них ничего.
*/
/** Сохранение формы: та же операция, что и кнопка «Сохранить». */
export type SaveHandler = () => Promise<void>;
export interface UnsavedEntry {
id: string;
save: SaveHandler;
}
interface UnsavedState {
entries: UnsavedEntry[];
/** Обёртка попросила закрыть окно, а в реестре есть правки. */
closePrompt: boolean;
/**
* Регистрирует запись и возвращает функцию снятия. Повторная регистрация с
* тем же id заменяет обработчик: форма может пересобрать `save`.
*/
register: (id: string, save: SaveHandler) => () => void;
unregister: (id: string) => void;
/** Показать диалог закрытия (сообщение `closeRequested` от обёртки). */
requestClose: () => void;
/** Закрыть диалог без выхода: пользователь выбрал «Отмена». */
dismissClose: () => void;
/**
* Сохранить все формы по очереди. Первая же ошибка останавливает процесс и
* уходит наверх: диалог покажет её текст, а окно останется открытым.
*/
saveAll: () => Promise<void>;
/** Сброс для тестов и выхода из аккаунта. */
reset: () => void;
}
const defaults = {
entries: [] as UnsavedEntry[],
closePrompt: false,
};
export const useUnsavedStore = create<UnsavedState>((set, get) => ({
...defaults,
register: (id, save) => {
const entries = get().entries;
const index = entries.findIndex((entry) => entry.id === id);
if (index === -1) {
set({ entries: [...entries, { id, save }] });
} else {
const next = [...entries];
next[index] = { id, save };
set({ entries: next });
}
return () => get().unregister(id);
},
unregister: (id) => {
const entries = get().entries;
if (!entries.some((entry) => entry.id === id)) {
return;
}
set({ entries: entries.filter((entry) => entry.id !== id) });
},
requestClose: () => set({ closePrompt: true }),
dismissClose: () => set({ closePrompt: false }),
saveAll: async () => {
// Копия списка: `save` может изменить реестр (например, форма сбросит
// черновик и снимет регистрацию), а обход должен остаться устойчивым.
for (const entry of [...get().entries]) {
await entry.save();
}
},
reset: () => set({ ...defaults }),
}));
/** Есть ли в реестре хотя бы одна несохранённая форма. */
export function hasUnsavedChanges(state: UnsavedState): boolean {
return state.entries.length > 0;
}
/**
* Платформа узнаёт о переходе «есть правки / нет правок» ровно один раз:
* сравниваем агрегат, а не длину списка. Иначе сохранение одной из двух форм
* мигало бы `setDirty(false)` → `setDirty(true)`, и обёртка успела бы решить,
* что закрывать окно можно.
*/
useUnsavedStore.subscribe((state, previous) => {
const dirty = hasUnsavedChanges(state);
if (dirty !== hasUnsavedChanges(previous)) {
platform.setDirty(dirty);
}
});