The Jerusalem PostEl Al unveils new bedding, Jo Malone kits in passenger service overhaulESPNHurts out for season after blood clots discovered in Eagles QB's lungsESPN DeportesNBA 2026-27: Rankings, predicciones sobre los 30 equiposInquirerDizon: 3 nabbed for stealing parts of Diokno Pumping StationPunchN3bn power line: Scrap dealers demand proof of cable theft from ZulumRTP DesportoChampions: Farioli quer começar a "escalar a montanha" frente ao PSVDaily MaverickTHE CONVERSATION: Why are we so obsessed with true crime? It’s not just morbid curiosityFootball ItaliaAllegri: ‘I question myself every day, Napoli players must be defended’Anime News NetworkSTEEL BALL RUN JoJo's Bizarre Adventure ‒ Episodes 1-4GIGAZINE規制が厳しい現代社会に「オトナ」なアニメ表現を届ける気概と工夫を男2人が密室でみっちり語り倒す『ダークサモナーとデキている』『彼女の友達』プロデューサートーク濃密レポート
The Daily Newsstand · Free, Always
Monday, October 19, 2026

Задачи из Jira и GitHub, встречи и заметки в одном окне: полгода соло‑разработки на Qt 6 / C++20

Translate

Утро у меня начиналось с вкладки трекера, вкладки календаря, заметок и пары 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;

Правила, к которым я в итоге пришёл:

  1. Новая версия поднимает старый файл на месте, а копию «до миграции» кладёт в backups/state-premigration-v<N>-<время>.json.

  2. Старая версия не пишет поверх файла от новой. Она открывает его только для чтения и предлагает обновиться. Версия 11 появилась как раз из‑за этого: у задач появились вложения, и сборка на v10 иначе тихо сохранила бы все задачи без файлов.

  3. Неизвестные ключи переживают сохранение. Если файл тронула более новая сборка или вы сами, поля, которых эта сборка не знает, записываются обратно как были.

  4. При переходе на 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 today, add и задача ветки в промпте

tabless today, add и задача ветки в промпте

Была одна чисто виндовая история. 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 tabless https://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 в репозитории открыты.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.