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.tsxwas 169 KiB with 25 test files importing it;ProjectSettingsTab.tsxis 90 KiB
Половина SessionPage.tsx закрылась за семь PR и два ADR (0122,
0124). Оба ADR назвали ProjectSettingsTab.tsx в разделах «вне
объёма», вместо того чтобы молча сузить строку, — и именно поэтому
он всё ещё здесь, и поэтому для его закрытия нужен собственный номер, а не
попутный рефакторинг.
Этот файл структурно намного чище, чем когда-либо был SessionPage.tsx,
и эта разница — вся причина того, что это один PR, а не
пять:
- Родитель ничего не хранит.
ProjectSettingsTab(строки 217–239 до этого изменения) — это 17 JSX-потомков и интерфейс props. Ни одного hook, ни одногоuseQuery, ни одногоuseQueryClient, ни одногоuseTranslation, ни одной проверки роли, ни одногоuseState— нечего распределять и согласовывать с циклом рендеринга React. - Ни один раздел не получает больше, чем
{ projectId: string }, а два (MatrixSection,CredentialsSection) не получают props вовсе. Нет prop-drilling данных, ни контекста, ни общегоuseState, ни callback, передаваемого вниз. Каждый раздел вызывает собственные hooks и владеет собственными queries. - 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 строк, 62it) импортирует 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.tsx | 80 | нет |
settings/ExecutionSection.tsx | 88 | да |
settings/ExecutionModeSection.tsx | 174 | да |
settings/ParallelismSection.tsx | 121 | да |
settings/BudgetSection.tsx | 132 | да |
settings/PromotionSection.tsx | 85 | да |
settings/MelhoresModelosPorCapacidadeSection.tsx | 194 | да |
settings/ModelsSection.tsx | 334 | да |
settings/AreaModelsSection.tsx | 143 | да |
settings/CatalogoDeModelos.tsx | 17 | нет |
settings/MembersSection.tsx | 184 | нет |
settings/PersonalAccessTokensSection.tsx | 284 | да |
settings/ProficiencySection.tsx | 256 | да |
settings/InstructionVersionsSection.tsx | 161 | нет |
settings/MatrixSection.tsx | 65 | нет |
settings/CredentialsSection.tsx | 269 | да |
settings/GastoDasChaves.tsx | 22 | нет |
settings/shared.ts | 30 | — |
Подпапка, а не 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 из четырёх файлов, sandboxTerminalExecutor, незакрытый критерий Фазы 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, перестаёт отвечать на вопрос, ради ответа на который существует.