Перейти к основному содержимому

ADR 0125 — ProjectSettingsTab.tsx разделён на файл на раздел, за barrel

  • Статус: принят
  • Дата: 2026-08-30
  • Ссылки (без редактирования): ADR 0121 (ближайший брат — декомпозиция на основе barrel, мерилом приёмки которой было «импортёры ничего не меняют»; этот ADR переиспользует эту форму на стороне web), ADR 0122 и ADR 0124 (оба назвали этот файл и оба явно его отложили — «совершенно отдельное решение об объёме, всё ещё открытое»; этот ADR и есть это решение, принятое).

Контекст​

apps/web/src/routes/ProjectSettingsTab.tsx содержит 2 532 строки и 89,9 KiB. Это последняя открытая половина строки технического долга в docs/architecture.md и docs/explanation/backlog.md:

Decompose SessionPage.tsx / ProjectSettingsTab.tsx | G | SessionPage.tsx was 169 KiB with 25 test files importing it; ProjectSettingsTab.tsx is 90 KiB

Половина SessionPage.tsx закрылась за семь PR и два ADR (0122, 0124). Оба ADR назвали ProjectSettingsTab.tsx в разделах «вне объёма», вместо того чтобы молча сузить строку, — и именно поэтому он всё ещё здесь, и поэтому для его закрытия нужен собственный номер, а не попутный рефакторинг.

Этот файл структурно намного чище, чем когда-либо был SessionPage.tsx, и эта разница — вся причина того, что это один PR, а не пять:

  1. Родитель ничего не хранит. ProjectSettingsTab (строки 217–239 до этого изменения) — это 17 JSX-потомков и интерфейс props. Ни одного hook, ни одного useQuery, ни одного useQueryClient, ни одного useTranslation, ни одной проверки роли, ни одного useState — нечего распределять и согласовывать с циклом рендеринга React.
  2. Ни один раздел не получает больше, чем { projectId: string }, а два (MatrixSection, CredentialsSection) не получают props вовсе. Нет prop-drilling данных, ни контекста, ни общего useState, ни callback, передаваемого вниз. Каждый раздел вызывает собственные hooks и владеет собственными queries.
  3. 11 из 17 разделов уже экспортируются — набор тестов уже нарисовал большую часть границы, тот же аргумент, что ADR 0122 привёл для пяти helpers временной шкалы SessionPage.

Дублирование между разделами намеренное и остаётся: useTranslation('settings') появляется ~14×, useQueryClient() 11×, useToast() 10×, useCurrentWorkspaceWithRole() 5× и useQuery({ queryKey: ['project', projectId] }) 7×. React Query дедуплицирует эти вызовы в runtime — именно это делает шаблон безопасным и каждый раздел независимо монтируемым. Поднятие их в общий родитель или контекст было бы изменением поведения, наряженным в уборку, и здесь не делается.

Решение​

Один файл на раздел в apps/web/src/routes/settings/, плюс общий модуль helpers — 18 новых файлов — и ProjectSettingsTab.tsx остаётся по текущему пути как точка входа и barrel.

Barrel структурный, а не декоративный​

Три тестовых файла зависят от пути или от экспортируемых имён, и все три должны проходить без редактирования — это и есть всё мерило приёмки этого PR:

  • apps/web/src/routes/ProjectSettingsTab.test.tsx (1 356 строк, 62 it) импортирует 11 именованных exports из ./ProjectSettingsTab.
  • apps/web/src/routes/ProjectPage.test.tsx и apps/web/src/routes/project-tabs.test.tsx оба делают vi.mock('./ProjectSettingsTab', …) по пути — перемещение или переименование файла молча ломает оба mock.
  • apps/web/src/routes/ProficiencySection.test.tsx импортирует сам ProjectSettingsTab и рендерит его, что делает его единственным тестом, монтирующим все 17 разделов сразу, — сквозным доказательством того, что barrel подключён в правильном порядке с правильными props.

То же рассуждение, что у db/schema.ts из ADR 0121: старый путь продолжает работать, поэтому ничего ниже по течению не меняется. Там, где 0121 использовал export *, этот barrel явно называет каждый export, потому что раздел настроек — это React-компонент с конкретной формой props, а не таблица, — явный список позволяет компилятору поймать раздел, переставший совпадать с местом вызова. Новые разделы входят в barrel на место по теме, никогда не дописываются в конец — то же правило, что закрепил 0121, и здесь позиция буквальна: список imports barrel, список re-exports и JSX все следуют порядку рендеринга.

17 файлов, в порядке рендеринга​

файлстрокэкспортируется barrel
settings/RepositorySection.tsx80нет
settings/ExecutionSection.tsx88да
settings/ExecutionModeSection.tsx174да
settings/ParallelismSection.tsx121да
settings/BudgetSection.tsx132да
settings/PromotionSection.tsx85да
settings/MelhoresModelosPorCapacidadeSection.tsx194да
settings/ModelsSection.tsx334да
settings/AreaModelsSection.tsx143да
settings/CatalogoDeModelos.tsx17нет
settings/MembersSection.tsx184нет
settings/PersonalAccessTokensSection.tsx284да
settings/ProficiencySection.tsx256да
settings/InstructionVersionsSection.tsx161нет
settings/MatrixSection.tsx65нет
settings/CredentialsSection.tsx269да
settings/GastoDasChaves.tsx22нет
settings/shared.ts30—

Подпапка, а не 18 соседей в routes/: прецедент — routes/code/, а 17 файлов на одну вкладку — ровно тот размер, при котором папка оправдана. Это отличается от PR 2–4 ADR 0122, которые положили извлечённые компоненты поодиночке рядом с SessionPage.tsx, — один-два соседа не оправдывают папку; семнадцать оправдывают.

Экспортируется собственным файлом, приватно для barrel​

6 разделов, которые сегодня приватны (RepositorySection, CatalogoDeModelos, MembersSection, InstructionVersionsSection, MatrixSection, GastoDasChaves), получают каждый по export в собственном модуле — это механическое следствие изменения, а не решение: barrel не может составить функцию, которую не может импортировать.

Они не реэкспортируются barrel. Эта часть является решением, и причина в том, что список exports barrel — публичная поверхность этой вкладки, а механическое изменение не должно её расширять. Сделать barrel единообразным для всех 17 тоже было бы оправданно, но означало бы, что этот PR изменил нечто наблюдаемое — множество exports модуля, — утверждая, что ничего не меняет. Последующий PR, которому действительно нужен один из 6, импортирует его из собственного файла, что теперь возможно, а раньше не было.

Общие helpers: только то, что вызывает более одного раздела​

Двенадцать helpers уровня модуля переместились. Применённое правило механическое: helper, используемый ровно одним разделом, идёт в файл этого раздела; helper, используемый двумя или более, идёт в settings/shared.ts. Проверено grep, а не предположено, — и проверка исправила одно ожидание: iniciaisDe, PARES_DE_GRADIENTE и gradienteDe вызываются только MembersSection, никогда ProficiencySection.

  • settings/shared.ts (2 helpers): ORIGIN_TONE (ModelsSection + AreaModelsSection) и formatarCustoMicros (ModelsSection + BudgetSection).
  • В файле единственного вызывающего (10): USO_TONE → MelhoresModelosPorCapacidadeSection; MATRIX_ROWS → MatrixSection; iniciaisDe, PARES_DE_GRADIENTE, gradienteDe → MembersSection; siglaDoConector, COR_DO_CONECTOR → CredentialsSection; LEVEL_TONE и identidadeDe → ProficiencySection; DEFAULT_MAX_CONSECUTIVE_BLOCKED → ExecutionSection; MODOS_DE_EXECUCAO → ExecutionModeSection.

shared.ts из двух символов мал намеренно. Альтернатива — общий модуль со всеми двенадцатью — поставила бы десять helpers на одну косвенность от единственного читателя без всякой выгоды и заставила бы следующего человека гадать, является ли константа общей. Два символа в shared.ts означают ровно то, что говорят: эти два, и только эти два, действительно общие.

CSS-модуль остаётся единым и общим​

ProjectSettingsTab.module.css сохраняет текущий путь и импортируется 15 файлами разделов, которые его используют (CatalogoDeModelos и GastoDasChaves лишь рендерят делегированный компонент и не ссылаются ни на один класс). Одна таблица стилей, пятнадцать импортёров, там, где раньше был один.

Это тот же ответ, что ADR 0122 дал для SessionPage.module.css, и по той же причине: второй копии никогда не было, поэтому расходиться нечему. Разветвление файла по разделам создало бы пятнадцать шансов для рамки .section/.sectionHead/.title/.eyebrow — которую каждый раздел рендерит одинаково — разойтись, в обмен ни на что.

Последствия​

  • ProjectSettingsTab.tsx уменьшается с 2 532 строк / 89,9 KiB до 77 строк / 4,1 KiB. 17 разделов плюс shared.ts в сумме дают 2 639 строк в 18 файлах, крупнейший — ModelsSection.tsx с 334. Рост относительно оригинала (2 532 → 2 716 включая barrel) — целиком заголовки imports и сам barrel; ни одна строка тела раздела не изменилась.
  • Это закрывает последний открытый пункт технического долга, объявленный в docs/architecture.md. Строка, называвшая SessionPage.tsx и ProjectSettingsTab.tsx, закрывается в обеих половинах; остальные строки этой таблицы — другие долги (контракт api↔engine из четырёх файлов, sandbox TerminalExecutor, незакрытый критерий Фазы 4a), а не этот.
  • Мерило приёмки — ноль изменений наблюдаемого поведения, доказанное всем набором тестов web — 142 файла, 1 537 тестов — проходящим при нуле отредактированных тестовых файлов, включая три, ограничивающих путь и экспортируемые имена. Механическая проверка поддерживает ревью: каждый блок каждого нового файла был дословно сравнён с файлом до изменения из git show dev:…, и каждая непустая строка оригинала, от заголовка imports вниз, была подтверждена как попавшая в файл раздела или в barrel. Единственные правки, сделанные изменением, — добавленные слова export, перечисленные выше, и заголовки imports по файлам.
  • Два человека теперь могут редактировать два раздела настроек, не трогая один файл — та же выгода, которую ADR 0121 заявил для схемы, с 17 единицами вместо 16.
  • Четыре улучшения UI придут в эти же файлы в последующих PR этого же усилия (принятый план из 8 PR, в котором этот — PR 0): оглавление с якорями для вкладки, единая формулировка для унаследованных значений в четырёх местах, которые пишут её по-разному, гранулярность сохранения по разделу только для ParallelismSection/BudgetSection/ CredentialsSection (три раздела с автосохранением сохраняют автосохранение, намеренно) и читаемая цепочка происхождения модели в ModelsSection/AreaModelsSection. Ни одного из них нет в этом PR. Выполнение перемещения первым — то, что делает каждое из этих четырёх маленьким diff в именованном файле вместо большого diff в файле из 2 500 строк, — и поэтому план поставил его первым.
  • Дублирование остаётся, и остаётся намеренным. Четырнадцать вызовов useTranslation('settings') теперь разбросаны по четырнадцати файлам вместо того, чтобы быть сосредоточенными в одном, что делает шаблон более видимым, а не менее. Это не дефект, который надо исправить позже: разделы монтируемы независимо именно потому, что ни один не зависит от того, чтобы родитель вызвал hook заранее.

Отброшенные альтернативы​

  • Разделить на пять PR, как ADR 0122 сделал для SessionPage.tsx. Отклонено: форма пяти PR в 0122 была навязана активным оттоком (50 коммитов, 25 зависимых тестовых файлов, переплетённый поток управления) и тем, что PR 5 не был перемещением файла. Здесь каждый раздел уже независим, родитель не хранит состояния, и каждый файл — непрерывный блок, идентичный байт в байт, — аргумент оттока неприменим, и пять PR были бы пятью ревью одного и того же тривиального diff.
  • Удалить ProjectSettingsTab.tsx и направить project-tabs.ts на settings/index.tsx. Отклонено по доказательствам, а не по вкусу: два тестовых файла делают vi.mock модуля по пути, а третий импортирует из него 11 имён. Маршрут продолжал бы работать; три тестовых файла сломались бы из-за переименования, которое ничего не покупает.
  • Поднять общие queries (['project', projectId], useToast, useTranslation) в родителя и передавать результаты вниз. Отклонено: превращает 17 самодостаточных компонентов в 17 компонентов с контрактом родителя, что является изменением дизайна, а не места, — а React Query уже дедуплицирует запросы, поэтому «дублирование» ничего не стоит в runtime. Это также сделало бы каждый раздел невозможным для изолированного монтирования, а именно так ProjectSettingsTab.test.tsx сегодня проверяет 11 из них.
  • Поместить двенадцать helpers уровня модуля в shared.ts. Отклонено: у десяти из двенадцати ровно один вызывающий, а общий модуль, хранящий приватные helpers, перестаёт отвечать на вопрос, ради ответа на который существует.