Утро у меня начиналось с вкладки трекера, вкладки календаря, заметок и пары PR, и полгода назад я начал делать себе десктопное приложение, которое собирает это в одно окно с клавиатуры. Ниже — что внутри и где я по дороге наступил на грабли: 13 трекеров через один дескриптор, все данные в одном JSON с миграциями и машиной времени, Vim‑клавиши по физическому месту клавиши и задача, которую подсказывает git‑ветка. Называется Tabless, код открыт под MIT.
1. Сколько вкладок нужно, чтобы понять, что делать сейчас
Если ваша работа живёт в Jira или GitHub, картина знакомая. Как общее место командный трекер хорош: видно, кто что делает, что заблокировано и что уехало в релиз. А вот мой собственный день он показывает плохо. Встречи — в календаре. Мысль со созвона — в заметках или нигде. Что я начал вчера и на какой ветке остановился, помнит только голова, и то не всегда.
Ещё один трекер мне был не нужен. Нужен был личный стол рядом с командным:
-
задачи, назначенные на меня, из всех трекеров, где они есть;
-
сегодняшние встречи рядом с задачами;
-
Markdown‑заметки, в которых
#APP-112становится ссылкой на задачу; -
всё с клавиатуры, в одном окне, и данные у меня на диске.
Первый коммит — 15 мая 2 026 года. На момент написания в репозитории 1 202 коммита и 339 смёрженных PR. Дальше — какие решения оказались важными и чем я за них заплатил.
2. Electron продержался полдня
Третий коммит в истории называется «Wrap design/ in Electron so the desktop app is ready to compile». Четвёртый, в тот же день, — «Replace Electron wrapper with native Qt 6 + QML desktop app». Да, вот так быстро.
Причин было три. Приложение открыто весь день, и держать ради списка задач ещё один браузерный движок рядом с IDE, браузером и мессенджером не хотелось. Нужны были системные вещи: глобальный хоткей из любого приложения, иконка в трее, чтение .git/HEAD по событию файловой системы, токены в системном хранилище. И, честно говоря, мне просто интереснее писать на C++. Для другой задачи я бы Electron, может, и оставил.
Стек получился скучный, в хорошем смысле: Qt 6 и QML для интерфейса, C++20 для всего остального, CMake. Браузерного движка внутри нет. Приложение работает, пока открыто окно или иконка в трее; запуск при входе в систему выключен, пока вы его не включите.
Сейчас это около 79 тысяч строк C++ в 281 файле и около 62 тысяч строк QML в 152 файлах, считая комментарии и пустые строки. Тестов — 2 548 на GoogleTest и 1 323 на Qt Quick Test. Много? Для приложения, которое хранит чужие задачи в одном файле, — в самый раз.
3. 13 трекеров через один дескриптор
Первые интеграции я писал руками: класс на трекер, свой разбор JSON, своя пагинация, своя карточка в настройках. На третьем трекере стало понятно, что я пишу один и тот же код с разными строками. Отличались только данные: какой URL, какой заголовок авторизации, где в ответе массив задач и как найти следующую страницу.
Так что теперь REST‑трекер — это одна функция, которая заполняет структуру ProviderDescriptor, а исполняет её общий RestIssueProvider. Карточка в настройках, хранение токена, пагинация, повторы, синхронизация и доска берутся из дескриптора. Вот Redmine целиком, самый маленький провайдер:
ProviderDescriptor redmine() {
ProviderDescriptor d;
d.id = QStringLiteral("redmine"); // stable: it keys settings and secrets
d.displayName = QStringLiteral("Redmine");
d.uiFields = {plain(QStringLiteral("host"), QStringLiteral("Host"), QStringLiteral("https://redmine.example.com")),
secret(QStringLiteral("token"), QStringLiteral("API key"))};
d.requiredKeys = {QStringLiteral("host"), QStringLiteral("token")};
d.secretKeys = {QStringLiteral("token")}; // keychain, never state.json
d.baseUrlTemplate = QStringLiteral("{host}");
d.auth.kind = AuthKind::CustomHeader;
d.auth.headerName = "X-Redmine-API-Key";
d.listPathTemplate = QStringLiteral("/issues.json?assigned_to_id=me&status_id=open&limit=100");
d.paging.style = PageStyle::Offset;
d.paging.offsetParam = QStringLiteral("offset");
d.paging.pageSize = 100;
d.fields.arrayPointer = QStringLiteral("issues");
d.fields.id = QStringLiteral("id");
d.fields.title = QStringLiteral("subject");
d.fields.status = QStringLiteral("status.name"); // dot-paths into one issue object
d.fields.urlTemplate = QStringLiteral("{baseUrl}/issues/{id}");
d.fields.dueAt = QStringLiteral("due_date");
return d; // pull-only
}
Неочевидное здесь — пути к полям. Это маленький язык: a.b заходит во вложенный объект, assignees.0.login берёт элемент массива, a|b — первое непустое. Пагинация — один из трёх рецептов: заголовок Link (GitHub, GitLab, Gitea), ссылка на следующую страницу в теле ответа (Bitbucket, Todoist, Asana) или шаг по offset (Redmine). Gitea и Forgejo — вообще один дескриптор с разными адресами по умолчанию.
Так подключены 13 трекеров: GitHub, GitLab, Jira, Trello, Gitea, Forgejo, Redmine, Todoist, Asana, ClickUp, Sentry, Bitbucket и Mattermost (для @упоминаний, а не задач). В схему не влезли двое. Jira — из‑за JQL, спринтов и своей модели статусов. Trello — из‑за токена в query‑строке. Они живут отдельными классами за тем же интерфейсом, и это нормально: универсальное решение, в которое насильно впихнули Jira, было бы хуже двух честных исключений.
Одни грабли про порядок. Список провайдеров в providerCatalog() пересортировать нельзя: на позиции завязаны сохранённые настройки, и тест прибивает GitHub к нулевому индексу. Новый трекер добавляется только в конец.
Войти можно через браузер (OAuth с возвратом на 127.0.0.1, приложение пароля не видит) или токеном. Токены в state.json не попадают. В Windows‑сборке они лежат в системном хранилище учётных данных через QtKeychain. В сборках для macOS и Linux QtKeychain пока нет, и токены лежат в secrets.json, который может читать только ваш пользователь. Это не идеально, и я это знаю.
Если вашего трекера нет, а REST API у него есть, добавить его — обычно одна функция строк на 40 и тест на сохранённом ответе. Пошагово: Add your tracker. Открытые запросы: YouTrack, Plane, Shortcut, Linear, Azure DevOps (issues #348–352). Linear (только GraphQL) и Azure DevOps (сначала WIQL‑запрос, потом выборка) в общую схему не влезают, им нужен свой класс.
4. Единственная запись в чужую систему
Самое важное правило интеграций появилось раньше, чем половина самих интеграций: приложение читает трекер, а пишет в него только то, что пользователь явно разрешил. Каждая запись во внешнюю систему стоит за своим переключателем, на каждый трекер отдельно, и по умолчанию он выключен. И на новой установке, и после обновления.
Сейчас такая запись ровно одна: статус задачи, когда двигаешь карточку между колонками. Она есть для GitHub, GitLab, Gitea, Forgejo и Jira; остальные трекеры только читаются. Заголовки, описания и комментарии не пишутся никогда.
С выключенным переключателем перемещение карточки — локальное. Ничего не отправляется и не встаёт в очередь, и следующая синхронизация карточку обратно не утащит: колонку выбрали вы.
С включённым приложение перед каждой записью заново смотрит на задачу в трекере:
-
Она всё ещё в вашем фильтре? Если её переназначили или она выпала из JQL, ничего не отправляется. Диалог показывает задачу, её статус в трекере сейчас и целевой статус. «Отправить всё равно» — единственный выход, и по умолчанию выбрана отмена.
-
Она всё ещё в том статусе, который приложение видело последним? Если кто‑то сдвинул её в трекере, ничего не отправляется, и вы видите конфликт с выбором.
Слепая запись в общий трекер — ровно то, от чего это правило защищает. Трекер, который нельзя спросить о текущем состоянии, записи не получает вообще. Логика простая: трекер общий, и одна неудачная автоматическая запись видна всей команде. Личный инструмент не должен шуметь в общем месте.
5. Свой слой поверх чужой задачи
Задача из трекера принадлежит команде. Но у меня к ней всегда есть что‑то своё: заметка «пробовал уменьшить пул, не помогло», чеклист, свой приоритет, своя дата, метка «после релиза».
Если хранить это в тех же полях, что и данные трекера, первая же синхронизация всё перезапишет. Поэтому у задачи есть объект local, в который синхронизация не пишет никогда: ни при обычном обновлении, ни когда в конфликте выбираешь сторону трекера, ни при переподключении интеграции, ни при импорте.
"local": {
"notes": "tried a smaller pool — did not help",
"checklist": [{ "id": "c1", "text": "repro", "level": 1, "done": true }],
"myPriority": "P0", "myPriorityBase": "P2",
"myDueAt": "2026-10-15T12:00:00.000", "myDueHasTime": true,
"tags": [{ "id": "after-release", "color": "#b1a7f0" }]
}
Приоритет и срок трекера остаются в полях задачи, ваши — в myPriority и myDueAt. Поля …Base запоминают, что говорил трекер в момент, когда вы поменяли значение. Всё, что сортирует по приоритету или дате, сначала читает ваше. Обратно в трекер из этого слоя не уходит ничего.
6. Все данные — один state.json
Аккаунта нет, сервера нет. Всё состояние — один файл state.json в папке данных приложения: профили, задачи, люди, колонки, заметки, встречи и настройки. Его можно открыть в редакторе, забэкапить копированием или прочитать скриптом.
Пишется он через QSaveFile: новый файл пишется рядом и подменяет старый целиком, так что падение посреди сохранения не оставит половину JSON. Если файл заблокирован антивирусом или программой синхронизации, приложение открывается только для чтения с красной плашкой, а не пытается писать поверх.
Миграции схемы
У файла есть schemaVersion, сейчас 12. История версий записана прямо в заголовке сериализатора, и по ней хорошо видно, как менялась модель:
// v2 profiles array, events nested per profile
// v3 events hoisted to the top level
// v4 Task.deadline (QDate) split into scheduledAt/dueAt (QDateTime) + hasTime
// v5 Task.rank — manual order within a status column
// v10 Task.hasTime split into dueHasTime / scheduledHasTime; ...
// v11 Task.attachments and Profile.savedViews (no rung: absent = none)
// v12 Task.local — what the developer keeps on top of a tracker issue
inline constexpr int kSchemaVersion = 12;
Правила, к которым я в итоге пришёл:
-
Новая версия поднимает старый файл на месте, а копию «до миграции» кладёт в
backups/state-premigration-v<N>-<время>.json. -
Старая версия не пишет поверх файла от новой. Она открывает его только для чтения и предлагает обновиться. Версия 11 появилась как раз из‑за этого: у задач появились вложения, и сборка на v10 иначе тихо сохранила бы все задачи без файлов.
-
Неизвестные ключи переживают сохранение. Если файл тронула более новая сборка или вы сами, поля, которых эта сборка не знает, записываются обратно как были.
-
При переходе на 12 ничего не теряется. Ваши правки поверх задачи трекера (приоритет, срок, заголовок) переезжают в
local, а исходный текст трекера возвращается в задачу.
Поле, которое забыли сохранить
Самая неприятная ошибка в такой схеме — добавить поле в структуру и забыть про него в сериализаторе. Всё компилируется, всё работает до перезапуска, а потом поле молча пропадает. Ревью такое пропускает легко: diff выглядит нормально.
Я закрыл это на этапе компиляции. В C++20 можно посчитать число полей агрегата: пробуем инициализировать его всё большим числом объектов, которые приводятся к чему угодно, пока requires не скажет «нельзя».
struct Any {
template<class T>
constexpr operator T() const noexcept;
};
template<class T, class... Args>
constexpr std::size_t countFields() {
if constexpr(requires { T{Args{}..., Any{}}; }) {
return countFields<T, Args..., Any>();
} else {
return sizeof...(Args);
}
}
В обоих сериализаторах (файл состояния и синхронизация) стоят static_assert(fieldCount<Task>() == 28) и такой же для встреч. Добавил поле — сборка падает в каждом месте, которое ещё не умеет его сохранять, пока не допишешь сериализацию и тест на круговое сохранение. Немного раздражает, зато ошибка ловится до того, как дойдёт до чьего‑то файла.
7. Бэкапы и машина времени
Бэкап и машина времени отвечают на разные вопросы. Бэкап — на «файл сломался». Машина времени — на «во вторник я удалил проект, верните».
-
Бэкапы: копия
state.jsonв папкеbackups/, по умолчанию раз в день, хранятся последние 20. -
Машина времени: после успешного сохранения, если последний снимок старше часа, делается новый. Последние 48 часов можно отмотать по часу, последние 30 дней — по дню.
Снимки сжимаются qCompress с уровнем 1. На профиле в 10 тысяч задач JSON сжимается примерно в 8 раз, и уровень 1 по размеру почти не отличается от максимального, а пишется быстрее. Перед восстановлением текущее состояние всегда снимается отдельно, так что откат тоже можно откатить.
Вложения (файлы к задачам) в бэкапы не входят: они лежат в attachments/ под именем из SHA-256 содержимого, один файл на все задачи, которые на него ссылаются. Чтобы сохранить всё, копируйте папку данных целиком.
8. Vim‑клавиши, которые работают на русской раскладке
Клавиатурная схема построена на Vim: j k по списку, g b — доска, g c — календарь, d — готово, y b — скопировать имя ветки, Ctrl+K — командная строка, ? — шпаргалка со всеми клавишами. Однобуквенные команды работают, только когда фокус не в текстовом поле. Любую клавишу можно переназначить.
Проблему знает каждый, кто пишет по‑русски: переключил раскладку, нажал Ctrl+K, а приложение получило Ctrl+Л и ничего не сделало. Qt в QKeyEvent::key() отдаёт символ в текущей раскладке, и QShortcut сравнивает именно его.
Решение — читать не символ, а место клавиши. У QKeyEvent есть nativeVirtualKey() и nativeScanCode(), и на каждой платформе из них можно восстановить «какая это клавиша на QWERTY»:
-
Windows: виртуальные коды
VK_A…VK_ZиVK_OEM_*привязаны к положению клавиши, а не к раскладке. -
macOS: коды
kVK_ANSI_*тоже называют клавишу по её месту. -
Linux X11: keycode (evdev + 8) основного блока.
-
Wayland и всё остальное: здесь я нативным кодам пока не доверяю и беру запасную таблицу ЙЦУКЕН → QWERTY:
й→q,х→[,ё→`, плюс точка, которая в русской раскладке стоит на клавише/. Для украинской и белорусской раскладок таблица та же.
Сама развилка по платформам короткая:
NativeKeys nativeKeysFor(const QString& platformName) {
if(platformName == QLatin1String("windows")) return NativeKeys::Windows;
if(platformName == QLatin1String("cocoa")) return NativeKeys::Mac;
if(platformName == QLatin1String("xcb")) return NativeKeys::X11;
return NativeKeys::None;
}
Из этого получается «аккорд» в нормальной форме (Ctrl+K, g b), и все привязки хранятся и сравниваются только в ней. Поэтому g b на русской раскладке — это п и, а Ctrl+K — это Ctrl+Л. Если раскладка латинская, приложение свои сочетания у Qt не перехватывает: перехват нужен только там, где символ и место разошлись.
Пара мелочей нашлась не в теории, а при тестировании. Зажатая клавиша навигации повторяется, а зажатая клавиша действия срабатывает один раз: иначе зажатое e архивировало колонку карточка за карточкой. Двойное d в пределах полсекунды (привычка dd) не отменяет «готово». Префиксы g, y, z, c ждут вторую клавишу секунду, и внизу видно, что может идти следом.
9. Ветка подсказывает, над чем я работаю
Делаю git switch APP-112-fix-login — и задача APP-112 оказывается наверху окна, с состоянием её PR. Плагинов в IDE для этого не нужно.
Как это устроено:
-
Репозитории добавляются в настройках.
gitдля ветки приложение не запускает: оно читает.git/HEADи следит за ним черезQFileSystemWatcher. -
С worktree всё веселее: в
.gitлежит ссылка на приватную папку worktree, где есть толькоHEADиindex, а ветки иpacked-refs— в общем репозитории, на который указывает файлcommondir. Следить приходится за обоими местами. -
Идентификатор задачи ищется в имени ветки по известным префиксам с границами
-,/,_. Если в ветке есть чужой ключ видаABC-123, голые цифры номером вашей задачи не считаются. -
Состояние PR берётся из вашего же
ghилиglab, примерно раз в минуту и только для добавленных репозиториев. Если ни того, ни другого нет вPATH, эта часть просто выключается. -
При смене ветки задача переходит в «В работе». Это отключается одним переключателем.
10. Быстрый ввод и задача в промпте
Ctrl+Shift+Space поверх любого приложения открывает быстрый ввод. Пишу fix login tomorrow 14:00 p1 #backend — дата, время, приоритет и метка вытаскиваются из текста. То же работает по‑русски: «позвонить Ане завтра в 15:00» или «созвон завтра в 19:00 по Москве», с часовым поясом. Разбор дат — свой парсер с отдельными таблицами для английского и русского.
Иногда открывать окно вообще незачем. Для этого есть CLI:
tabless add "fix login tomorrow 14:00 p1 #backend"
tabless today # сначала просроченное, потом сегодняшнее
tabless now # текущая задача: с запущенным таймером или та, что названа в ветке
tabless done APP-12
Если окно открыто на тех же данных, команды уходят в него через локальный сокет, и изменение сразу видно и отменяется там же. Если закрыто, CLI сам читает и сохраняет state.json. В сеть CLI не ходит.
tabless now ничего не печатает и выходит с кодом 0, если текущей задачи нет. Значит, его можно поставить в промпт:
# starship.toml
[custom.tabless]
command = "tabless now --format '{id} {title}'"
when = true
format = "[$output]($style) "
Была одна чисто виндовая история. tabless.exe — GUI‑программа, а cmd и PowerShell такие программы не ждут: промпт возвращается сразу, вывод приходит после него, код возврата теряется. Пришлось сделать отдельный консольный tabless-cli.exe. На now, list и today он отвечает сам, загружая только QtCore, — этого хватает, чтобы промпт не тормозил. Остальное передаёт tabless.exe со своими дескрипторами консоли и возвращает его код выхода.
11. Сколько стоит нативность
Qt дал то, ради чего я ушёл с Electron. Но бесплатно не вышло, и вот счёт.
Размер. Нативное не значит маленькое. Установщик для Windows в 0.9.4 весит 43 МБ, portable‑архив — 65 МБ, образ для macOS — 66 МБ, AppImage — 54 МБ: Qt, QML‑модули и шрифты едут внутри. Сравнивать память с Electron‑аналогами я не стал: честный замер требует одинаковых сценариев, а без него это были бы просто красивые цифры.
Зависимости на Windows. windeployqt собирает Qt, но не всё, что Qt тянет за собой. Релиз 0.4.3 ушёл без Qt SVG и без универсальной CRT: на чистой Windows он просто не запускался, а у меня, разумеется, всё работало. Теперь сборка обходит всё дерево DLL через objdump и проверяет его отдельным гейтом.
Версии Qt. Локально я собираю на Qt 6.11, CI — на 6.9. QML, который у меня собирался, в CI ронял qmlcachegen, причём в сообщении был файл, который этот код использует, а не тот, что я менял. В 6.9 String.arg принимает один аргумент, а long нельзя использовать как имя. Всё это ловится только сборкой на минимальной поддерживаемой версии, так что она у меня в CI на каждый PR.
QML‑сюрпризы. property color onAccent молча остаётся чёрным: свойство с префиксом on и заглавной буквой QML считает обработчиком сигнала. font.family принимает одно имя, а не список, как в CSS, и если у пользователя установлен шрифт с тем же именем, что встроенный, глифы ломаются. Встроенные шрифты пришлось переименовать.
Подпись. Сборки пока не подписаны платным сертификатом. Бесплатный сервис подписи для open source проектов приложение не принял, а взнос Apple пока не покрыт. Поэтому при первом запуске Windows покажет SmartScreen, а macOS попросит открыть через контекстное меню. В каждом релизе есть SHA256SUMS и SBOM, встроенный апдейтер не ставит файл с несовпавшей суммой, и всё можно собрать самому.
В итоге я получил то, что хотел: глобальный хоткей, трей, слежение за файлами, системное хранилище токенов и приложение без браузера внутри. Стоило ли оно этого — для меня да; для команды с дедлайном я бы считал заново.
12. Чего тут нет
Это личный инструмент. Каких‑то вещей в нём нет специально, каких‑то — пока.
-
Он не строит план дня за вас. Он показывает факты: что назначено, что запланировано, какие встречи. Решения ваши.
-
Нет командной доски. Общим местом остаётся командный трекер.
-
Нет мобильного и веб‑приложения. Только Windows, macOS и Linux.
-
Нет синхронизации между машинами в реальном времени. Сейчас это экспорт и импорт профиля в JSON, причём импорт только добавляет. Или копия
state.json, пока приложение закрыто. -
Сборки не подписаны (выше), а на macOS и Linux токены лежат не в системной связке ключей, а в файле с правами только для вашего пользователя.
Что приложение само отправляет в сеть: проверку обновлений при старте (выключается), синхронизацию только подключённых трекеров (автосинхронизация по умолчанию выключена), .ics по вашим ссылкам и gh/glab для добавленных репозиториев. Аналитики нет. После падения приложение предлагает отчёт, который вы читаете и отправляете сами как issue на GitHub. Полный список — на сайте.
Как попробовать
-
Релизы: github.com/sectapunterx/tabless/releases/latest — Windows (установщик или portable), macOS, Linux AppImage.
-
Windows через Scoop:
scoop bucket add tablesshttps://github.com/sectapunterx/tabless, затемscoop install tabless. -
macOS через Homebrew:
brew install --cask sectapunterx/tabless/tabless. -
Сайт с туром: tabless.dev. Интерфейс на русском и английском.
-
Сборка из исходников: Qt 6.9+ и компилятор с C++20.
Just makes your day easier.
Если попробуете — напишите, что сломалось и какой трекер добавить следующим. И вопрос, который мне правда интересен: как вы сейчас держите личный список задач рядом с командным трекером — отдельное приложение, заметки, вкладки, голова? Issues и Discussions в репозитории открыты.
Автор: luxaeterna13
