Доброго времени суток, дорогой читатель. Меня зовут Михаил, я Android-разработчик в компании Циан. Этой статьёй я открываю для себя цикл статей по внутренней кухне разработки плагинов для Jetbrains IDE: IDEA, Android Studio (AS) и пр. На дворе 2024 год, официальная документация не так богата информацией, как хотелось бы. Но есть исходники, которые смело можно дербанить. В этом цикле статей я буду описывать свой опыт поиска нужной мне информации и её разбор.

В статье мы разберёмся с тем, как отобразить каталог не из проекта в project tool. 

Я занимаюсь разработкой плагина «Group File Template ​(GFT)», GFT на Market, GitHub В своей предыдущей статье я описал, как им пользоваться. Весь мой опыт работы с плагинами крутится вокруг него. Поэтому в своём блоге буду рассказывать, как я делал тот или иной функционал этого плагина.

Итак, приступим. Плагин GFT я использую как Android-разработчик. Что это значит? Я работаю с проектом, который лежит в одном репозитории — в нём пишут свой код все Android-разработчики. Мы все имеем одну точку синхронизации — ветку develop. Поэтому, при актуальном develop разработчик имеет актуальные шаблоны для GFT.

Посмотрев статистику по плагину, можно увидеть, что его скачивают не только для AS, но и для других Jetbrains IDE.

Таких скачиваний немного, но и плагин не очень полезный для работы с большим количеством репозиторий. Допустим, у вас 100 репозиториев с проектами, одинаковой архитектурой и синтаксисом. Концепция «содержать шаблоны внутри проекта» для вас крайне не удобная. Ведь нужно содержать 100 одинаковых шаблонов в актуальном состоянии. Звучит как подвиг, достойный Геракла.

А значит, надо научить плагин сохранять шаблоны внутри IDE. Тогда можно будет держать шаблоны в одном месте.

Проблему обозначили, накидаем MVP

План MVP:

  1. При клике правой кнопкой мыши на шаблон GFT, в контекстном меню появляются кнопки «Copy Template» и «Move Template»

  2. Перемещаем или копируем шаблон в каталог с IDE

  3. При копировании даём возможность переименовать шаблон

  4. Отображаем наши шаблоны в project tool.

План небольшой, фича простая. Ну, часа 4 работы, — подумал я и приступил.

День первый. Что такое каталог IDE?

На часах 20:10, классика, приступим. Создаем Action для перемещения шаблонов — MigrateTemplateAction : Action(). Нам нужны 2 кнопочки, значит регистрируем наш Action в plugin.xml 2 раза, с разным именем и ID.

<action
       id="MigrateTemplateActionCopy"
       class="com.arch.temp.actions.MigrateTemplateAction"
       text="Copy Template">
   <add-to-group group-id="ProjectViewPopupMenu" anchor="after" relative-to-action="CutCopyPasteGroup"/>
</action>

<action
       id="MigrateTemplateActionRemove"
       class="com.arch.temp.actions.MigrateTemplateAction"
       text="Move Template">
   <add-to-group group-id="ProjectViewPopupMenu" anchor="after" relative-to-action="MigrateTemplateActionCopy"/>
</action>

Мне показалось логичным запихнуть оба Action в блок — CutCopyPasteGroup.

В Action делаем так, чтобы наши кнопки показывались только при вызове контекстного меню на папке с шаблоном. Для этого переопределяем метод update, пишем наше условие и вуаля — всё работает!

override fun update(e: AnActionEvent) {
   val project = e.project
   val pathTemplate = e.getData(CommonDataKeys.VIRTUAL_FILE)?.path
   val shortMainTemplate = File("$pathTemplate/$MAIN_SHORT_FILE_TEMPLATE")
   val shirtTemplate = File("$pathTemplate/$MAIN FILE TEMPLATE")
   e.presentation.isEnabledAndVisible = project != null && (shortMainTemplate.isFile || shortTemplate.isFile)
}

Посмотрел на часы и подумал: «Пфф. Чёт, походу, быстрее сделаю».

Теперь давайте реализуем в нашем Action метод actionPerformed(actionEvent: AnActionEvent). Выглядит всё просто: мы нажимаем на «Copy Template» или «Move Template» и метод вызывается. 

Берём из actionEvent.getData(CommonDataKeys.VIRTUAL_FILE) VirtualFile каталога, по которому мы кликнули. Отлично! Первый путь, указывающий откуда перемещать, у нас есть. Далее, берём путь указывающий куда перемещать. Так, стоп, а откуда его брать? Что такое каталог в IDE?

Начинаем отвечать на вопросы:

  • Что такое каталог IDE? Звучит как то, где лежат конфиг файлы самой IDE. Хотя тут я испытываю сомнения.

  • Откуда брать? Ну, наверное, должен быть какой-нибудь путь, не на уровне project, а на уровне application.

Не понятно. Ладно. Значит что? Правильно, ставим брейкпоинт и начинаем шерстить всё, что примерно может соответствовать нашим вопросам. На часах 00:30. 

Нашёл чуть больше, чем ничего. Пару прикольных методов, например, File.separatorChar, он отдает разделитель в зависимости от OS компа. Полезно, но не то, что я искал. 

Лааааадно. Иду в документацию, почти ничего не нахожу. Всё не то. На часах 01:20. Мы проиграли эту битву, пора спать.

День второй. Scratch files

Ночью мне не приснилось гениальное решение. Поэтому, шерстю форумы, блоги и натыкаюсь на Scratch files

Scratch-файлы — это полнофункциональные, работоспособные и отлаживаемые файлы, которые поддерживают подсветку синтаксиса, завершение кода и все другие функции для соответствующего типа файлов. Например, во время работы над одним проектом у вас может возникнуть идея метода, который позже можно будет использовать в другом проекте. Вы можете создать рабочий файл с черновиком метода, который не хранится в каталоге вашего проекта, но может быть открыт при работе над любым другим проектом.

Ура! Первая стоящая зацепка! Это же то, что я хочу сделать, ну прям точь-в-точь. Раньше я думал, что это Scratch-файлы — это временные файлы. Всякую требуху туда кидал: JSON из запроса покрутить, main функцию по быстрому вызвать. А оказывается, я миксером шурупы кручу.

Если Scratch-файлы не хранятся в нашем проекте, значит они хранятся где-то во внешнем каталоге. Начинаем наш сёрч, на часах 20:15. Тапаем дважды на Shift, вбиваем Scratch.

Находим 2 объекта: ScratchFileService и ScratchTreeStructureProvider. Заходим в ScratchFileService и находим метод getRootPath.

public @SystemIndependent @NotNull String getRootPath(@NotNull RootType rootType) {
       return myRootPaths.get(rootType.getId());
}

Что мы видим: myRootPaths — это ConcurrentMap<String, String> myRootPaths = ConcurrentFactoryMap.createMap(ScratchFileServiceImpl::calcRootPath), из которого по rootType.Id вытаскивается путь. Лезем в calcRootPath.

private static @SystemIndependent @NotNull String calcRootPath(@NotNull String rootId) {
 String path = System.getProperty(PathManager.PROPERTY_SCRATCH_PATH + "/" + rootId);
 if (path != null && path.length() > 2 && path.charAt(0) == '\"') {
       path = StringUtil.unquoteString(path);
 }
 return path != null ? FileUtil.toSystemIndependentName(path) :
       FileUtil.toSystemIndependentName(PathManager.getScratchPath()) + "/" + rootId;
}

Что видно? По каким-то свойствам, мы получаем путь к root-каталогу. PROPERTY_SCRATCH_PATH = "idea.scratch.path"

Свойства не обычные, к ним плюсуется какой-то rootId. К тому же, как подкаталог. 

Находим ScratchRootType extends RootType. RootType в конструкторе принимает myId и myDisplayName.

ScratchRootType() {
      super("scratches", LangBundle.message("root.type.scratches"));
}

RootId получен — это «scratches». Ещё мы видим, что ScratchRootType зарегистрирован в plugin.xml, значит надо не забыть сделать также.

<scratch.rootType implementation="com.intellij.ide.scratch.ScratchRootType"/>

У RootType находим метод getAllRootTypes(). Он возвращает все зарегистрированные реализации RootType.

Кайф, получается я могу отнаследоваться от RootType, зарегистрировать его — и у меня будет путь к моему каталогу с шаблонами. На часах 21:20 первые строки кода понеслись.

Создаю GFTemplateRootType: RootType(ROOT_ID, GFT_ROOT_NAME) и регистрирую его в plugin.xml.

<extensions defaultExtensionNs="com.intellij">
   <scratch.rootType implementation="com.arch.temp.extension.GFTemplateRootType"/>
</extensions>

Запускаю, вызываю getAllRootTypes(), вижу в списке GFTemplateRootType. Теперь для получения пути позаимствуем метод у ScratchFileServiceImpl.getInstance().getRootPath(rootType). Получаю путь указывающий куда класть шаблоны. Победа!!! Мы знаем точку А и точку Б.

Реализую логику в MigrateTemplateAction, делаю разделение по тексту кнопки. И внимательный читатель в недоумении «Серьезно? По тексту? У нас же есть уникальные ID!» Прикинь, я так и не нашёл, как из Action вытащить его ID (если ты что-то об этом знаешь, беги писать в комменты, добрый человек), это ещё час потраченный в пустую. Перед перемещением вызываем диалог для переименования шаблона.

И собственно всё — наши шаблоны сохраняются в IDE. Но мы пока не видим их в списке шаблонов. Понеслась! Добавляю в ListTemplateActionGroup дополнительный root каталог, в котором мы проверяем наличие шаблонов.

fun AnActionEvent.getListTemplate(): List<MainClassJson> {
   val listTemplate = mutableListOf<MainClassJson>()


   getData(CommonDataKeys.PROJECT)?.let {
       val basePath = getBasePathTemplate()
       listTemplate.addAll(getListMainClassTemplate(basePath))
   }
   val pathHomeTemplate = getRootPathTemplate()
   listTemplate.addAll(getListMainClassTemplate(pathHomeTemplate))
   return listTemplate
}

Запуск и проверка.

Всё работает и даже создаёт всё корректно. На часах 00:40, награждаем себя шоколадкой и спать.

День третий/четвертый/пятый. База

Большую часть сделал. Остались пустяки — показать шаблоны в project tool. Все звёзды сложились так, что у Scratch файлов есть свой раздел.

Просто беру и всё, что есть в ScratchTreeStructureProvider, копирую себе. «Обмазываемся» брейкпоинтами, начинаем ювелирить: тут отрезать, сюда приделать, тут переделать. Что-то не работает, берём куски из других мест. Не буду томить, выдаю «Базу» как нам законнектиться с project tool.

Создаём класс TemplateProjectView и наследуемся от AbstractProjectViewPane или AbstractProjectViewPaneWithAsyncSupport.

Регистрируем TemplateProjectView класс в plugin.xml в разделе extension.

<extensions defaultExtensionNs="com.intellij">
   <projectViewPane implementation="com.arch.temp.projectView.TemplateProjectView"/>
</extensions>

Переопределяем методы и разбираем.

override fun getTitle() = GFT_HOME_TEMPLATES \\ Имя на ProjectViewPane

override fun getIcon() = AllIcons.General.ProjectTab \\ Иконка

override fun getId() = TEMPLATE_VIEW_ID \\ ID по которому можем узнать статус панели и обращаться к ней

override fun getWeight() = 111 \\ Расположение в выпадающем списке чем больше число тем ниже панель

Теперь мы можем увидеть пункт «GFT Templates» в выпадающем меню.

Идём дальше. Когда из списка мы выбираем наше ProjectView, вызывается createTree. Затем createStructure. Я это интерпретировал так, что вначале мы создаём канвас, а затем накидываем на него структуру.

override fun createStructure(): ProjectAbstractTreeStructureBase {
   return ProjectViewPaneTreeStructure()
}

override fun createTree(treeModel: DefaultTreeModel): ProjectViewTree {
   return object : ProjectViewTree(treeModel) {
       override fun toString(): String {
           return title + " " + super.toString()
       }
   }
}

Реализуем нашу структуру.

inner class ProjectViewPaneTreeStructure : ProjectTreeStructure(myProject, id), ProjectViewSettings {
   override fun createRoot(project: Project, settings: ViewSettings): AbstractTreeNode<*> {
       return TemplateViewProjectNode(project, settings)
   }

   override fun isShowLibraryContents() = false 
}

Возвращаем в методе isShowLibraryContents false, для того чтобы не показывало вкладку с либами.

Есть похожие методы, они по умолчанию false: isShowScratchesAndConsoles, isShowModules и т. д.

Метод createRoot требует от нас реализацию некой AbstractTreeNode, из которой и состоит вся структура.

В AbstractTreeNode хранится вся необходимая информация для структуры: имя, цвет, иконка, её наследники (если это директория) и всё, всё, всё, что отвечает за отображение элемента.

Реализуем свою ProjectViewNode, для этого переопределяем следующие методы:

  • canRepresent, contains. Срабатывают при нажатии SelectOpenedFile

  • update. Обновляет представление ноды getChildren(): List<AbstractTreeNode<*>>. Здесь пишем всю логику того, что мы хотим показать.

Я решил накостылить и завёл два RootType: один это rootPath, второй projectPath (ну вот захотелось так, для единообразия). Я их получаю через RootType.getAllRootTypes(), по ID. 

В итоге у нас получается два пункта в структуре: Ide GFTemplates и Project GFTemplates.

Наполняем их, вытаскиваем путь. Это pathProject/template и rootPath/template.

Находим VirtualFile и рекурсивно пробежимся по дочерним элементам. Если это файл, то вернем PsiFileNode, если каталог, то PsiDirectoryNode.

Вот и всё. В структуре мы видим все наше дерево с файлами шаблонов из разных мест. УРА!

Тестируем. Для этого создаём новый шаблон. К несчастью, он не появился в структуре. Пробуем переходить по вкладкам — и неожиданно он появляется. Беда, структура не обновляется самостоятельно, а значит, не делает её принудительное обновление.

В главной ноде при вызове метода init регистрируем слушатель на обновления виртуальных файлов.

VirtualFileManager.getInstance().addAsyncFileListener({ events: List<VFileEvent?>? ->
   val update = JBIterable.from(events)
       .find { e: VFileEvent? ->
           ProgressManager.checkCanceled()
           val parent = getNewParent(e!!)
           if (TemplateUtils.isTemplate(parent)) return@find true
           if (!isDirectory(e)) return@find false
           false
       } != null
   if (!update) null else object : AsyncFileListener.ChangeApplier {
       override fun afterVfsChange() {
           onUpdate.run()
       }
   }
}, project)

Пишу логику на отслеживание того, что в директориях происходят изменения. Обновляем отображение. Запуск. Создаем шаблон — появился, фух! Кликаем на него и копируем, появляется копия в каталоге «Ide GFTemplates».

Кликаем в место где хотим создать шаблон, смотрим список, он отобразился, DONE!

Выводы

Можно было не использовать ScratchFileServiceImpl и rootType? Скорей всего да, если сесть и разобраться, как зарегистрировать своё свойство и потом обращаться к нему из системы, то легко. Но, в погоне за скоростью реализации и слабым погружением в эту тему, имеем что имеем.

Возможно есть ещё какая-нибудь реализация и она удобнее. Я не нашёл, но чувствую, что есть.

Слабая документация рождает интерес достичь рабочей программы из кусков примера, который скрыт от понимания, а также вдохновляет делиться этим необычным опытом с вами. Мне понравилось! Да и фича вышла полезной. 

Кстати, если посмотреть доку по projectViewPane из кода, то можно увидеть, кто ещё его использовал.

Мы увидим чуть больше 10 плагинов. Теперь и с моим на борту =)

Надеюсь, моя статья была для тебя полезна. Не поленись поставить лайк и написать приятный коммент. Если есть неприятный, тоже пиши — обсудим. Удачи, не болей!

З.Ы. Если твой плагин работает с перемещением / копированием / созданием каталогов или файлов, лучше делать это через Virtual Files. Project tool подписан на их изменения, если использовать Java IO API, то отображение будет обновляться с задержкой. 

Комментарии (4)


  1. grisha9
    23.05.2024 06:51
    +1

    Получить ID события можно так - ActionManager.getInstance().getId(this)

    Но по моему мнению, создавать два разных события и вешать их обработку на один класс это неправильно. В итоге внутри получаем лишние проверки. Когда можно было сразу разнести на два класса и избежать этого. Хотя если платформа такое допускает... то может все норм.

    Также резануло глаз, то как вы работаете с путями и хардкодите слешь. Такое не будет работать в win. Причем самое удивительное, что далее вы пишите все верно, что в java есть соответствующая константа File.separatorChar , и что с путями правильно работать через JDK Path или через виртуальную файловую систему Virtual Files которую предоставляет платформа IDEA.

    Правильно делать вот так, через Virtual File:

    val pathTemplate = e.getData(CommonDataKeys.VIRTUAL_FILE)
    val shortMainTemplate = pathTemplate.findChild(MAIN_SHORT_FILE_TEMPLATE)

    Или вот так через Path:

    val pathTemplate = e.getData(CommonDataKeys.VIRTUAL_FILE)?.toNioPath()
    val shortMainTemplate = pathTemplate.resolve(MAIN_SHORT_FILE_TEMPLATE)


    1. Louco11 Автор
      23.05.2024 06:51

      Спасибо большое за замечания =) Про Action я с вами согласен, но мне было очень лень дублировать, логика переноса от копирования отличается что в конце я просто удалю каталог, по этому посчитал что это тот случай когда ничего страшного в этом не вижу =) Про слэш спасибо что заметили, глаз уже замылен, не всегда удается подчищать =)


  1. anominy
    23.05.2024 06:51
    +1

    Идея с шаблонами довольно интересная. Хотелось бы знать, если пользовались или писали, ваше мнение насчёт встроенного в IDEA движка — Apache Velocity, для генерации бойлерплейт кода на подобии геттеров и сеттеров, что вызывается через Alt&Insert. И разницу проблем решающие ваш и встроенный инструменты. Поверхностно я пока вижу, что плагин может быть полезен для генерации структуры каталогов. Но было бы интересно узнать поподробнее, как вы его используете в своих целях.


    1. Louco11 Автор
      23.05.2024 06:51

      Если честно, когда я искал решения, то не натыкался на этот движок — Apache Velocity. Когда я начал делать своё решение, появились запросы на изменение параметра ввода. Суть в том, что когда мы вводим параметр name, он может быть написан по-разному в разных местах, но его сущность не меняется. Например, NewFeature в зависимости от параметра, который мы выставим в шаблоне, может выглядеть как new_feature, new.feature, newFeature и т. д.
      У себя мы используем это в KMP, когда для новой фичи нам нужно создать множество структур, не только каталогов, но и файлов, в которых можно сразу писать логику. Проблематику и применение я описывал вот тут https://habr.com/ru/companies/cian/articles/740928/ .