diff --git a/markdown-preview-plugin/.gitignore b/markdown-preview-plugin/.gitignore new file mode 100644 index 0000000000..6af499b76d --- /dev/null +++ b/markdown-preview-plugin/.gitignore @@ -0,0 +1,29 @@ +# Gradle +.gradle/ +build/ +gradle-app.setting +!gradle-wrapper.jar +.gradletasknamecache + +# IDE +.idea/ +*.iml +*.ipr +*.iws +.project +.classpath +.settings/ +.kotlin/ + +# Local configuration +local.properties + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Test outputs +test-results/ diff --git a/markdown-preview-plugin/build.gradle.kts b/markdown-preview-plugin/build.gradle.kts new file mode 100644 index 0000000000..e697a907d3 --- /dev/null +++ b/markdown-preview-plugin/build.gradle.kts @@ -0,0 +1,75 @@ +plugins { + id("com.android.application") version "8.8.2" + id("org.jetbrains.kotlin.android") version "2.1.21" + id("com.itsaky.androidide.plugins.build") +} + +pluginBuilder { + pluginName = "markdown-previewer" +} + +android { + namespace = "com.codeonthego.markdownpreviewer" + compileSdk = 34 + + defaultConfig { + applicationId = "com.codeonthego.markdownpreviewer" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "1.0.0" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + + packaging { + resources { + excludes += setOf( + "META-INF/versions/9/OSGI-INF/MANIFEST.MF", + "META-INF/DEPENDENCIES", + "META-INF/LICENSE", + "META-INF/LICENSE.txt", + "META-INF/NOTICE", + "META-INF/NOTICE.txt" + ) + } + } +} + +dependencies { + compileOnly(project(":plugin-api")) + + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("com.google.android.material:material:1.10.0") + implementation("androidx.fragment:fragment-ktx:1.8.8") + implementation("org.jetbrains.kotlin:kotlin-stdlib:2.1.21") + implementation("androidx.fragment:fragment:1.8.8") + implementation("androidx.webkit:webkit:1.9.0") + + // Markdown parsing library + implementation("io.noties.markwon:core:4.6.2") + implementation("io.noties.markwon:ext-tables:4.6.2") + implementation("io.noties.markwon:ext-strikethrough:4.6.2") + implementation("io.noties.markwon:ext-tasklist:4.6.2") + implementation("io.noties.markwon:html:4.6.2") + implementation("io.noties.markwon:image:4.6.2") + implementation("io.noties.markwon:linkify:4.6.2") +} + +tasks.wrapper { + gradleVersion = "8.10.2" + distributionType = Wrapper.DistributionType.BIN +} diff --git a/markdown-preview-plugin/gradle.properties b/markdown-preview-plugin/gradle.properties new file mode 100644 index 0000000000..f0a2e55f89 --- /dev/null +++ b/markdown-preview-plugin/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true diff --git a/markdown-preview-plugin/gradle/wrapper/gradle-wrapper.jar b/markdown-preview-plugin/gradle/wrapper/gradle-wrapper.jar new file mode 100755 index 0000000000..d64cd49177 Binary files /dev/null and b/markdown-preview-plugin/gradle/wrapper/gradle-wrapper.jar differ diff --git a/markdown-preview-plugin/gradle/wrapper/gradle-wrapper.properties b/markdown-preview-plugin/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..c0cb293e89 --- /dev/null +++ b/markdown-preview-plugin/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists \ No newline at end of file diff --git a/markdown-preview-plugin/gradlew b/markdown-preview-plugin/gradlew new file mode 100755 index 0000000000..1aa94a4269 --- /dev/null +++ b/markdown-preview-plugin/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/markdown-preview-plugin/gradlew.bat b/markdown-preview-plugin/gradlew.bat new file mode 100755 index 0000000000..93e3f59f13 --- /dev/null +++ b/markdown-preview-plugin/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/markdown-preview-plugin/proguard-rules.pro b/markdown-preview-plugin/proguard-rules.pro new file mode 100644 index 0000000000..b0ad322801 --- /dev/null +++ b/markdown-preview-plugin/proguard-rules.pro @@ -0,0 +1,10 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /sdk/tools/proguard/proguard-android.txt + +# Keep Markwon classes +-keep class io.noties.markwon.** { *; } +-keep class org.commonmark.** { *; } + +# Keep plugin classes +-keep class com.codeonthego.markdownpreviewer.** { *; } diff --git a/markdown-preview-plugin/settings.gradle.kts b/markdown-preview-plugin/settings.gradle.kts new file mode 100644 index 0000000000..b7c4727d91 --- /dev/null +++ b/markdown-preview-plugin/settings.gradle.kts @@ -0,0 +1,22 @@ +rootProject.name = "markdown-previewer-plugin" + +pluginManagement { + includeBuild("../plugin-api/plugin-builder") + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS) + repositories { + mavenCentral() + google() + gradlePluginPortal() + } +} + +include(":plugin-api") +project(":plugin-api").projectDir = file("../plugin-api") diff --git a/markdown-preview-plugin/src/main/AndroidManifest.xml b/markdown-preview-plugin/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..42774f7ac1 --- /dev/null +++ b/markdown-preview-plugin/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/markdown-preview-plugin/src/main/kotlin/com/codeonthego/markdownpreviewer/MarkdownPreviewerPlugin.kt b/markdown-preview-plugin/src/main/kotlin/com/codeonthego/markdownpreviewer/MarkdownPreviewerPlugin.kt new file mode 100644 index 0000000000..936f44cf6e --- /dev/null +++ b/markdown-preview-plugin/src/main/kotlin/com/codeonthego/markdownpreviewer/MarkdownPreviewerPlugin.kt @@ -0,0 +1,195 @@ +package com.codeonthego.markdownpreviewer + +import androidx.fragment.app.Fragment +import com.itsaky.androidide.plugins.IPlugin +import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.extensions.UIExtension +import com.itsaky.androidide.plugins.extensions.EditorTabExtension +import com.itsaky.androidide.plugins.extensions.MenuItem +import com.itsaky.androidide.plugins.extensions.TabItem +import com.itsaky.androidide.plugins.extensions.EditorTabItem +import com.itsaky.androidide.plugins.extensions.NavigationItem +import com.itsaky.androidide.plugins.extensions.ContextMenuContext +import com.itsaky.androidide.plugins.services.IdeEditorTabService +import com.codeonthego.markdownpreviewer.fragments.MarkdownPreviewFragment +import java.io.File + +class MarkdownPreviewerPlugin : IPlugin, UIExtension, EditorTabExtension { + + private lateinit var context: PluginContext + + companion object { + const val PLUGIN_ID = "com.codeonthego.markdownpreviewer" + private val MARKDOWN_EXTENSIONS = setOf("md", "markdown", "mdown", "mkd", "mkdn") + private val HTML_EXTENSIONS = setOf("html", "htm", "xhtml") + val SUPPORTED_EXTENSIONS = MARKDOWN_EXTENSIONS + HTML_EXTENSIONS + + fun isMarkdownFile(file: File): Boolean = + MARKDOWN_EXTENSIONS.contains(file.extension.lowercase()) + + fun isHtmlFile(file: File): Boolean = + HTML_EXTENSIONS.contains(file.extension.lowercase()) + + fun isSupportedFile(file: File): Boolean = + SUPPORTED_EXTENSIONS.contains(file.extension.lowercase()) + } + + override fun initialize(context: PluginContext): Boolean { + return try { + this.context = context + context.logger.info("MarkdownPreviewerPlugin: Plugin initialized successfully") + true + } catch (e: Exception) { + context.logger.error("MarkdownPreviewerPlugin: Plugin initialization failed", e) + false + } + } + + override fun activate(): Boolean { + context.logger.info("MarkdownPreviewerPlugin: Activating plugin") + return true + } + + override fun deactivate(): Boolean { + context.logger.info("MarkdownPreviewerPlugin: Deactivating plugin") + return true + } + + override fun dispose() { + context.logger.info("MarkdownPreviewerPlugin: Disposing plugin") + } + + // UIExtension - No main menu items (accessed via sidebar/context menu) + override fun getMainMenuItems(): List = emptyList() + + // UIExtension - Context menu for files + override fun getContextMenuItems(context: ContextMenuContext): List { + val file = context.file ?: return emptyList() + + if (!isSupportedFile(file)) { + return emptyList() + } + + val fileType = when { + isMarkdownFile(file) -> "Markdown" + isHtmlFile(file) -> "HTML" + else -> "File" + } + + return listOf( + MenuItem( + id = "preview_${file.name}", + title = "Preview $fileType", + isEnabled = true, + isVisible = true, + action = { + openPreviewerTabWithFile(file) + } + ) + ) + } + + // UIExtension - Bottom sheet tab + override fun getEditorTabs(): List = emptyList() + + // UIExtension - Sidebar navigation item + override fun getSideMenuItems(): List { + return listOf( + NavigationItem( + id = "markdown_preview_sidebar", + title = "Preview", + icon = android.R.drawable.ic_menu_gallery, + isEnabled = true, + isVisible = true, + group = "tools", + order = 10, + action = { + context.logger.info("Sidebar Preview clicked") + openPreviewerTab() + } + ) + ) + } + + // EditorTabExtension - Main editor tab to display the preview + override fun getMainEditorTabs(): List { + return listOf( + EditorTabItem( + id = "markdown_preview_main_tab", + title = "Preview", + icon = android.R.drawable.ic_menu_gallery, + fragmentFactory = { + context.logger.debug("Creating MarkdownPreviewFragment") + MarkdownPreviewFragment() + }, + isCloseable = true, + isPersistent = false, + order = 50, + isEnabled = true, + isVisible = true, + tooltip = "Preview Markdown and HTML files" + ) + ) + } + + override fun onEditorTabSelected(tabId: String, fragment: Fragment) { + context.logger.info("Editor tab selected: $tabId") + if (fragment is MarkdownPreviewFragment) { + fragment.checkPendingFile() + } + } + + override fun onEditorTabClosed(tabId: String) { + context.logger.info("Editor tab closed: $tabId") + } + + override fun canCloseEditorTab(tabId: String): Boolean { + return true + } + + private fun openPreviewerTab() { + context.logger.info("Opening Markdown Previewer tab") + + val editorTabService = context.services.get(IdeEditorTabService::class.java) + if (editorTabService == null) { + context.logger.error("Editor tab service not available") + return + } + + if (!editorTabService.isTabSystemAvailable()) { + context.logger.error("Editor tab system not available") + return + } + + try { + if (editorTabService.selectPluginTab("markdown_preview_main_tab")) { + context.logger.info("Successfully opened Markdown Previewer tab") + } else { + context.logger.warn("Failed to open Markdown Previewer tab") + } + } catch (e: Exception) { + context.logger.error("Error opening Markdown Previewer tab", e) + } + } + + private fun openPreviewerTabWithFile(file: File) { + context.logger.info("Opening Markdown Previewer tab with file: ${file.absolutePath}") + + // Store the file path temporarily for the fragment to pick up + PreviewState.pendingFilePath = file.absolutePath + + openPreviewerTab() + } +} + + +object PreviewState { + @Volatile + var pendingFilePath: String? = null + + fun consumePendingFile(): String? { + val path = pendingFilePath + pendingFilePath = null + return path + } +} diff --git a/markdown-preview-plugin/src/main/kotlin/com/codeonthego/markdownpreviewer/fragments/MarkdownPreviewFragment.kt b/markdown-preview-plugin/src/main/kotlin/com/codeonthego/markdownpreviewer/fragments/MarkdownPreviewFragment.kt new file mode 100644 index 0000000000..937298ca9d --- /dev/null +++ b/markdown-preview-plugin/src/main/kotlin/com/codeonthego/markdownpreviewer/fragments/MarkdownPreviewFragment.kt @@ -0,0 +1,824 @@ +package com.codeonthego.markdownpreviewer.fragments + +import android.app.Activity +import android.content.Intent +import android.graphics.Color +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.Button +import android.widget.LinearLayout +import android.widget.ProgressBar +import android.widget.ScrollView +import android.widget.TextView +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import com.codeonthego.markdownpreviewer.MarkdownPreviewerPlugin +import com.codeonthego.markdownpreviewer.PreviewState +import com.codeonthego.markdownpreviewer.R +import com.itsaky.androidide.plugins.base.PluginFragmentHelper +import com.itsaky.androidide.plugins.services.IdeProjectService +import com.itsaky.androidide.plugins.services.IdeFileService +import io.noties.markwon.Markwon +import io.noties.markwon.ext.tables.TablePlugin +import io.noties.markwon.ext.strikethrough.StrikethroughPlugin +import io.noties.markwon.ext.tasklist.TaskListPlugin +import io.noties.markwon.html.HtmlPlugin +import io.noties.markwon.image.ImagesPlugin +import io.noties.markwon.linkify.LinkifyPlugin +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File + +class MarkdownPreviewFragment : Fragment() { + + companion object { + private const val PLUGIN_ID = MarkdownPreviewerPlugin.PLUGIN_ID + } + + private var projectService: IdeProjectService? = null + private var fileService: IdeFileService? = null + + // UI Components + private lateinit var webView: WebView + private lateinit var progressBar: ProgressBar + private lateinit var statusContainer: LinearLayout + private lateinit var statusText: TextView + private lateinit var btnSelectFile: Button + private lateinit var btnSelectFromStorage: Button + private lateinit var btnToggleView: Button + private lateinit var currentFileText: TextView + private lateinit var placeholderText: TextView + private lateinit var emptyStateContainer: LinearLayout + private lateinit var sourceScrollView: ScrollView + private lateinit var sourceCodeText: TextView + + private var currentFile: File? = null + private var currentContent: String? = null + private var isShowingSource: Boolean = false + private lateinit var markwon: Markwon + private var pickFileLauncher: androidx.activity.result.ActivityResultLauncher? = null + + override fun onGetLayoutInflater(savedInstanceState: Bundle?): LayoutInflater { + val inflater = super.onGetLayoutInflater(savedInstanceState) + return PluginFragmentHelper.getPluginInflater(PLUGIN_ID, inflater) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Register activity result launcher early in lifecycle + pickFileLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val uri = result.data?.data + if (uri != null) { + loadFileFromUri(uri) + } + } + } + + // Get services from the plugin's service registry + runCatching { + val serviceRegistry = PluginFragmentHelper.getServiceRegistry(PLUGIN_ID) + projectService = serviceRegistry?.get(IdeProjectService::class.java) + fileService = serviceRegistry?.get(IdeFileService::class.java) + } + + // Initialize Markwon with extensions + markwon = Markwon.builder(requireContext()) + .usePlugin(TablePlugin.create(requireContext())) + .usePlugin(StrikethroughPlugin.create()) + .usePlugin(TaskListPlugin.create(requireContext())) + .usePlugin(HtmlPlugin.create()) + .usePlugin(ImagesPlugin.create()) + .usePlugin(LinkifyPlugin.create()) + .build() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_markdown_preview, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + initializeViews(view) + setupWebView() + setupClickListeners() + checkPendingFile() + } + + fun checkPendingFile() { + PreviewState.consumePendingFile()?.let { path -> + loadFile(File(path)) + } + } + + private fun initializeViews(view: View) { + webView = view.findViewById(R.id.web_view) + progressBar = view.findViewById(R.id.progress_bar) + statusContainer = view.findViewById(R.id.status_container) + statusText = view.findViewById(R.id.tv_status) + btnSelectFile = view.findViewById(R.id.btn_select_file) + btnSelectFromStorage = view.findViewById(R.id.btn_select_storage) + btnToggleView = view.findViewById(R.id.btn_toggle_view) + currentFileText = view.findViewById(R.id.tv_current_file) + placeholderText = view.findViewById(R.id.tv_placeholder) + emptyStateContainer = view.findViewById(R.id.empty_state_container) + sourceScrollView = view.findViewById(R.id.source_scroll_view) + sourceCodeText = view.findViewById(R.id.tv_source_code) + } + + private fun setupWebView() { + webView.settings.apply { + javaScriptEnabled = false + loadWithOverviewMode = true + useWideViewPort = true + builtInZoomControls = true + displayZoomControls = false + setSupportZoom(true) + cacheMode = WebSettings.LOAD_NO_CACHE + } + + webView.webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + hideProgress() + } + } + + // Set background color based on theme + val isDarkMode = isNightMode() + webView.setBackgroundColor(if (isDarkMode) Color.parseColor("#000000") else Color.WHITE) + } + + private fun setupClickListeners() { + btnSelectFile.setOnClickListener { + showProjectFilePicker() + } + + btnSelectFromStorage.setOnClickListener { + openFilePicker() + } + + btnToggleView.setOnClickListener { + toggleSourcePreview() + } + } + + private fun toggleSourcePreview() { + isShowingSource = !isShowingSource + updateViewMode() + } + + private fun updateViewMode() { + if (isShowingSource) { + // Show source code + webView.visibility = View.GONE + sourceScrollView.visibility = View.VISIBLE + sourceCodeText.text = currentContent ?: "" + btnToggleView.text = "Preview" + } else { + // Show preview + sourceScrollView.visibility = View.GONE + webView.visibility = View.VISIBLE + btnToggleView.text = "Source" + } + } + + private fun showProjectFilePicker() { + val project = projectService?.getCurrentProject() + if (project == null) { + showToast("No project available") + return + } + + // Find all supported files in the project + viewLifecycleOwner.lifecycleScope.launch { + showProgress("Scanning project...") + + val files = withContext(Dispatchers.IO) { + findSupportedFiles(project.rootDir) + } + + hideProgress() + + if (files.isEmpty()) { + showToast("No Markdown or HTML files found in project") + return@launch + } + + // Show file selection dialog + showFileSelectionDialog(files, project.rootDir) + } + } + + private fun findSupportedFiles(rootDir: File): List { + val supportedFiles = mutableListOf() + + rootDir.walkTopDown() + .filter { it.isFile && MarkdownPreviewerPlugin.isSupportedFile(it) } + .filter { !it.absolutePath.contains("/build/") && !it.absolutePath.contains("/.") } + .forEach { supportedFiles.add(it) } + + return supportedFiles.sortedBy { it.name.lowercase() } + } + + private fun showFileSelectionDialog(files: List, rootDir: File) { + val fileNames = files.map { file -> + file.absolutePath.removePrefix(rootDir.absolutePath + "/") + }.toTypedArray() + + android.app.AlertDialog.Builder(requireContext()) + .setTitle("Select File to Preview") + .setItems(fileNames) { _, which -> + loadFile(files[which]) + } + .setNegativeButton("Cancel", null) + .show() + } + + private fun openFilePicker() { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "*/*" + putExtra(Intent.EXTRA_MIME_TYPES, arrayOf( + "text/markdown", + "text/x-markdown", + "text/html", + "text/plain" + )) + } + pickFileLauncher?.launch(intent) ?: showToast("File picker not available") + } + + private fun loadFileFromUri(uri: Uri) { + viewLifecycleOwner.lifecycleScope.launch { + showProgress("Loading file...") + + val result = runCatching { + withContext(Dispatchers.IO) { + // Get actual filename from ContentResolver + val fileName = getDisplayNameFromUri(uri) ?: "file" + + // Read content from URI + val content = requireContext().contentResolver.openInputStream(uri)?.use { + it.bufferedReader().readText() + } ?: throw Exception("Could not read file") + + // Determine file type + val lowerName = fileName.lowercase() + val isMarkdown = lowerName.endsWith(".md") || + lowerName.endsWith(".markdown") || + lowerName.endsWith(".mdown") || + lowerName.endsWith(".mkd") || + lowerName.endsWith(".mkdn") || + // Fallback: check if content looks like markdown + (!lowerName.endsWith(".html") && !lowerName.endsWith(".htm") && + content.trim().startsWith("#")) + + Triple(content, isMarkdown, fileName) + } + } + + result.fold( + onSuccess = { (content, isMarkdown, fileName) -> + hideProgress() + currentContent = content + currentFileText.text = fileName + currentFileText.visibility = View.VISIBLE + placeholderText.visibility = View.GONE + emptyStateContainer.visibility = View.GONE + btnToggleView.visibility = View.VISIBLE + isShowingSource = false + webView.visibility = View.VISIBLE + sourceScrollView.visibility = View.GONE + btnToggleView.text = "Source" + renderContent(content, isMarkdown) + }, + onFailure = { e -> + hideProgress() + showError("Failed to load file: ${e.message}") + } + ) + } + } + + private fun getDisplayNameFromUri(uri: Uri): String? { + // Try to get display name from ContentResolver + return try { + requireContext().contentResolver.query(uri, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (nameIndex >= 0) { + cursor.getString(nameIndex) + } else null + } else null + } + } catch (e: Exception) { + // Fallback to path segment + uri.lastPathSegment?.substringAfterLast("/") + } + } + + private fun loadFile(file: File) { + if (!file.exists()) { + showError("File not found: ${file.name}") + return + } + + if (!file.canRead()) { + showError("Cannot read file: ${file.name}") + return + } + + currentFile = file + + viewLifecycleOwner.lifecycleScope.launch { + showProgress("Loading ${file.name}...") + + val result = runCatching { + withContext(Dispatchers.IO) { + file.readText() + } + } + + result.fold( + onSuccess = { content -> + hideProgress() + currentContent = content + currentFileText.text = file.name + currentFileText.visibility = View.VISIBLE + placeholderText.visibility = View.GONE + emptyStateContainer.visibility = View.GONE + btnToggleView.visibility = View.VISIBLE + isShowingSource = false + webView.visibility = View.VISIBLE + sourceScrollView.visibility = View.GONE + btnToggleView.text = "Source" + + val isMarkdown = MarkdownPreviewerPlugin.isMarkdownFile(file) + renderContent(content, isMarkdown) + }, + onFailure = { e -> + hideProgress() + showError("Failed to load file: ${e.message}") + } + ) + } + } + + private fun renderContent(content: String, isMarkdown: Boolean) { + val html = if (isMarkdown) { + convertMarkdownToHtml(content) + } else { + // Already HTML, just wrap with our styles + wrapHtmlContent(content) + } + + webView.loadDataWithBaseURL( + null, + html, + "text/html", + "UTF-8", + null + ) + } + + private fun convertMarkdownToHtml(markdown: String): String { + // Use Markwon to convert to HTML-like content + // We'll render to a Spanned first, then create styled HTML + val styled = markwon.toMarkdown(markdown) + + // Build HTML with proper styling + val isDarkMode = isNightMode() + val textColor = if (isDarkMode) "#E0E0E0" else "#121212" + val bgColor = if (isDarkMode) "#000000" else "#FFFFFF" + val codeBlockBg = if (isDarkMode) "#0D0D0D" else "#F5F5F5" + val codeBorder = if (isDarkMode) "#1A1A1A" else "#E0E0E0" + val linkColor = if (isDarkMode) "#64B5F6" else "#1976D2" + val blockquoteBorder = if (isDarkMode) "#616161" else "#BDBDBD" + val blockquoteBg = if (isDarkMode) "#0A0A0A" else "#FAFAFA" + val tableBorder = if (isDarkMode) "#1A1A1A" else "#DDDDDD" + val tableHeaderBg = if (isDarkMode) "#121212" else "#F0F0F0" + + return """ + + + + + + + + + ${convertMarkdownToBasicHtml(markdown)} + + + """.trimIndent() + } + + private fun convertMarkdownToBasicHtml(markdown: String): String { + var html = markdown + + // Code blocks (fenced with ```) - must be done first + html = html.replace(Regex("```(\\w*)\\n([\\s\\S]*?)```")) { match -> + val lang = match.groupValues[1] + val code = match.groupValues[2].trim() + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + "\n
$code
\n" + } + + // Inline code + html = html.replace(Regex("`([^`]+)`")) { match -> + val code = match.groupValues[1] + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + "$code" + } + + // Tables + html = convertTables(html) + + // Headers + html = html.replace(Regex("^######\\s+(.*)$", RegexOption.MULTILINE)) { "
${it.groupValues[1]}
" } + html = html.replace(Regex("^#####\\s+(.*)$", RegexOption.MULTILINE)) { "
${it.groupValues[1]}
" } + html = html.replace(Regex("^####\\s+(.*)$", RegexOption.MULTILINE)) { "

${it.groupValues[1]}

" } + html = html.replace(Regex("^###\\s+(.*)$", RegexOption.MULTILINE)) { "

${it.groupValues[1]}

" } + html = html.replace(Regex("^##\\s+(.*)$", RegexOption.MULTILINE)) { "

${it.groupValues[1]}

" } + html = html.replace(Regex("^#\\s+(.*)$", RegexOption.MULTILINE)) { "

${it.groupValues[1]}

" } + + // Bold and italic + html = html.replace(Regex("\\*\\*\\*(.+?)\\*\\*\\*")) { "${it.groupValues[1]}" } + html = html.replace(Regex("___(.+?)___")) { "${it.groupValues[1]}" } + html = html.replace(Regex("\\*\\*(.+?)\\*\\*")) { "${it.groupValues[1]}" } + html = html.replace(Regex("__(.+?)__")) { "${it.groupValues[1]}" } + html = html.replace(Regex("\\*([^*]+)\\*")) { "${it.groupValues[1]}" } + html = html.replace(Regex("_([^_]+)_")) { "${it.groupValues[1]}" } + + // Strikethrough + html = html.replace(Regex("~~(.+?)~~")) { "${it.groupValues[1]}" } + + // Links + html = html.replace(Regex("\\[([^\\]]+)\\]\\(([^)]+)\\)")) { + "${it.groupValues[1]}" + } + + // Images + html = html.replace(Regex("!\\[([^\\]]*)]\\(([^)]+)\\)")) { + "\"${it.groupValues[1]}\"" + } + + // Blockquotes + html = html.replace(Regex("^>\\s+(.*)$", RegexOption.MULTILINE)) { + "
${it.groupValues[1]}
" + } + + // Horizontal rules + html = html.replace(Regex("^(-{3,}|\\*{3,}|_{3,})$", RegexOption.MULTILINE)) { "
" } + + // Task lists (before regular lists) + html = html.replace(Regex("^\\s*-\\s+\\[x\\]\\s+(.*)$", RegexOption.MULTILINE)) { + "
  • ${it.groupValues[1]}
  • " + } + html = html.replace(Regex("^\\s*-\\s+\\[\\s\\]\\s+(.*)$", RegexOption.MULTILINE)) { + "
  • ${it.groupValues[1]}
  • " + } + + // Unordered lists + html = html.replace(Regex("^\\s*[-*+]\\s+(.*)$", RegexOption.MULTILINE)) { + "
  • ${it.groupValues[1]}
  • " + } + + // Ordered lists + html = html.replace(Regex("^\\s*\\d+\\.\\s+(.*)$", RegexOption.MULTILINE)) { + "
  • ${it.groupValues[1]}
  • " + } + + // Wrap consecutive
  • items in
      + html = html.replace(Regex("((?:]*>.*?\\s*)+)")) { match -> + val content = match.groupValues[1] + if (content.contains("task-list-item")) { + "
        $content
      " + } else { + "
        $content
      " + } + } + + // Paragraphs (wrap remaining text blocks) + val lines = html.split("\n") + val result = StringBuilder() + var inParagraph = false + + for (line in lines) { + val trimmed = line.trim() + val isBlockElement = trimmed.startsWith(" { + if (inParagraph) { + result.append("

      \n") + inParagraph = false + } + result.append("\n") + } + isBlockElement -> { + if (inParagraph) { + result.append("

      \n") + inParagraph = false + } + result.append(trimmed).append("\n") + } + else -> { + if (!inParagraph) { + result.append("

      ") + inParagraph = true + } else { + result.append("
      ") + } + result.append(trimmed) + } + } + } + if (inParagraph) { + result.append("

      ") + } + + return result.toString() + } + + private fun convertTables(markdown: String): String { + val lines = markdown.split("\n") + val result = StringBuilder() + var inTable = false + var headerProcessed = false + + for (i in lines.indices) { + val line = lines[i].trim() + + // Check if this is a table row (contains |) + if (line.startsWith("|") && line.endsWith("|")) { + // Check if next line is separator (|---|---|) + val isHeader = i + 1 < lines.size && + lines[i + 1].trim().matches(Regex("\\|[-:\\s|]+\\|")) + + // Check if this line IS a separator + val isSeparator = line.matches(Regex("\\|[-:\\s|]+\\|")) + + if (isSeparator) { + // Skip separator line + continue + } + + if (!inTable) { + result.append("\n") + inTable = true + headerProcessed = false + } + + val cells = line.split("|") + .filter { it.isNotBlank() } + .map { it.trim() } + + if (isHeader && !headerProcessed) { + result.append("") + cells.forEach { cell -> result.append("") } + result.append("\n\n") + headerProcessed = true + } else { + result.append("") + cells.forEach { cell -> result.append("") } + result.append("\n") + } + } else { + if (inTable) { + result.append("
      $cell
      $cell
      \n") + inTable = false + headerProcessed = false + } + result.append(line).append("\n") + } + } + + if (inTable) { + result.append("\n") + } + + return result.toString() + } + + private fun wrapHtmlContent(html: String): String { + // If already has tag, return as-is with dark mode support injected + if (html.lowercase().contains(" + + + + + + + + $html + + + """.trimIndent() + } + + private fun injectDarkModeStyles(html: String): String { + if (!isNightMode()) return html + + val darkModeStyle = """ + + """.trimIndent() + + return if (html.lowercase().contains("")) { + html.replace(Regex("", RegexOption.IGNORE_CASE)) { + "$darkModeStyle" + } + } else if (html.lowercase().contains("")) { + html.replace(Regex("", RegexOption.IGNORE_CASE)) { + "$darkModeStyle" + } + } else { + "$darkModeStyle$html" + } + } + + private fun isNightMode(): Boolean { + return (resources.configuration.uiMode and + android.content.res.Configuration.UI_MODE_NIGHT_MASK) == + android.content.res.Configuration.UI_MODE_NIGHT_YES + } + + private fun showProgress(message: String) { + progressBar.visibility = View.VISIBLE + statusContainer.visibility = View.VISIBLE + statusText.text = message + } + + private fun hideProgress() { + progressBar.visibility = View.GONE + statusContainer.visibility = View.GONE + } + + private fun showError(message: String) { + statusContainer.visibility = View.VISIBLE + statusText.text = message + statusText.setTextColor(ContextCompat.getColor(requireContext(), R.color.status_error_text)) + statusContainer.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.status_error_background)) + } + + private fun showToast(message: String) { + activity?.runOnUiThread { + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + } +} diff --git a/markdown-preview-plugin/src/main/res/drawable/ic_preview.xml b/markdown-preview-plugin/src/main/res/drawable/ic_preview.xml new file mode 100644 index 0000000000..4d5c08c1dc --- /dev/null +++ b/markdown-preview-plugin/src/main/res/drawable/ic_preview.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/markdown-preview-plugin/src/main/res/layout/fragment_markdown_preview.xml b/markdown-preview-plugin/src/main/res/layout/fragment_markdown_preview.xml new file mode 100644 index 0000000000..63d37a9122 --- /dev/null +++ b/markdown-preview-plugin/src/main/res/layout/fragment_markdown_preview.xml @@ -0,0 +1,173 @@ + + + + + + + + + + + + +