better tui for watch

This commit is contained in:
2026-03-24 21:05:23 +08:00
parent 6c59abb9cc
commit e419366615
2 changed files with 327 additions and 61 deletions

View File

@@ -31,6 +31,7 @@ executable hakysidian
, array , array
, directory , directory
, filepath , filepath
, process
, time , time
, wai-app-static , wai-app-static
, warp , warp

View File

@@ -1,4 +1,5 @@
{-# LANGUAGE BlockArguments #-} {-# LANGUAGE BlockArguments #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE LambdaCase #-} {-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE ScopedTypeVariables #-}
@@ -7,7 +8,10 @@
import ChaoDoc import ChaoDoc
import Control.Concurrent (forkIO, threadDelay) import Control.Concurrent (forkIO, threadDelay)
import Control.Monad (filterM, unless, void, when) import Control.Exception (SomeException, bracket_, try)
import Control.Monad (filterM, unless, void)
import Data.Char (isSpace)
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
import Data.Kind (Type) import Data.Kind (Type)
import Data.List (intercalate, isPrefixOf, sort, sortOn) import Data.List (intercalate, isPrefixOf, sort, sortOn)
import qualified Data.Map.Strict as M import qualified Data.Map.Strict as M
@@ -15,6 +19,8 @@ import Data.Maybe (fromMaybe)
import Data.String (fromString) import Data.String (fromString)
import qualified Data.Text as T import qualified Data.Text as T
import Data.Time.Clock (UTCTime) import Data.Time.Clock (UTCTime)
import Data.Time.Format (defaultTimeLocale, formatTime)
import Data.Time.LocalTime (getZonedTime)
import Hakyll import Hakyll
import Hakyll.Core.Runtime (RunMode (RunModeNormal)) import Hakyll.Core.Runtime (RunMode (RunModeNormal))
import qualified Network.Wai.Handler.Warp as Warp import qualified Network.Wai.Handler.Warp as Warp
@@ -27,10 +33,19 @@ import System.Directory
getModificationTime, getModificationTime,
listDirectory listDirectory
) )
import System.Environment (getArgs) import System.Environment (getArgs, getExecutablePath, lookupEnv)
import System.Exit (ExitCode (..), die, exitSuccess, exitWith) import System.Exit (ExitCode (..), die, exitSuccess, exitWith)
import System.FilePath import System.FilePath
import Network.Wai.Application.Static (staticApp) import Network.Wai.Application.Static (staticApp)
import System.IO
( BufferMode (NoBuffering),
hFlush,
hGetBuffering,
hIsTerminalDevice,
hSetBuffering,
stdout
)
import System.Process (CreateProcess (cwd), proc, readCreateProcessWithExitCode)
import Text.Pandoc (HTMLMathMethod (MathML), WriterOptions (..), compileTemplate) import Text.Pandoc (HTMLMathMethod (MathML), WriterOptions (..), compileTemplate)
import Text.Read (readMaybe) import Text.Read (readMaybe)
@@ -89,6 +104,30 @@ data CliCommand
type FileSnapshot :: Type type FileSnapshot :: Type
type FileSnapshot = M.Map FilePath UTCTime type FileSnapshot = M.Map FilePath UTCTime
type ServerStatus :: Type
data ServerStatus
= ServerDisabled
| ServerStarting
| ServerRunning
| ServerFailed String
deriving stock (Eq)
type DashboardState :: Type
data DashboardState = DashboardState
{ dashboardStatus :: String,
dashboardLastChange :: String,
dashboardLastBuild :: String,
dashboardLogLines :: [String]
}
deriving stock (Eq)
type TerminalSize :: Type
data TerminalSize = TerminalSize
{ terminalRows :: Int,
terminalCols :: Int
}
deriving stock (Eq)
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
-- https://www.rohanjain.in/hakyll-clean-urls/ -- https://www.rohanjain.in/hakyll-clean-urls/
cleanRoute :: Routes cleanRoute :: Routes
@@ -196,41 +235,17 @@ validateProject projectRoot = do
"hakysidian is missing required project inputs:" : "hakysidian is missing required project inputs:" :
map (" - " ++) missing map (" - " ++) missing
renderWatchPanel :: FilePath -> Configuration -> WatchSettings -> IO () initialDashboardState :: DashboardState
renderWatchPanel projectRoot config watchSettings = do initialDashboardState =
notesExists <- doesDirectoryExist (projectRoot </> "notes") DashboardState
bibExists <- doesFileExist (projectRoot </> "reference.bib") { dashboardStatus = "starting",
macrosExists <- doesFileExist (projectRoot </> "math-macros.md") dashboardLastChange = "waiting for first build",
imagesExists <- doesDirectoryExist (projectRoot </> "images") dashboardLastBuild = "pending",
let urlText = fromMaybe "disabled (--no-server)" (watchUrl watchSettings) dashboardLogLines =
statusText = [ "watcher ready",
"notes=" "watching notes/, reference.bib, math-macros.md, images/ (optional)"
++ presentStatus notesExists
++ ", bib="
++ presentStatus bibExists
++ ", macros="
++ presentStatus macrosExists
++ ", images="
++ optionalStatus imagesExists
mapM_
putStrLn
[ "------------------------------------------------------------",
"hakysidian watch",
"project : " ++ projectRoot,
"output : " ++ destinationDirectory config,
"url : " ++ urlText,
"watching : notes/, reference.bib, math-macros.md, images/ (optional)",
"status : " ++ statusText,
"------------------------------------------------------------"
] ]
}
presentStatus :: Bool -> String
presentStatus True = "ok"
presentStatus False = "missing"
optionalStatus :: Bool -> String
optionalStatus True = "present"
optionalStatus False = "absent"
parseWatchSettings :: Configuration -> [String] -> WatchSettings parseWatchSettings :: Configuration -> [String] -> WatchSettings
parseWatchSettings config args = parseWatchSettings config args =
@@ -272,6 +287,177 @@ extractOptionValue option = go
| optionPrefix `isPrefixOf` arg = Just (drop (length optionPrefix) arg) | optionPrefix `isPrefixOf` arg = Just (drop (length optionPrefix) arg)
| otherwise = go (value : rest) | otherwise = go (value : rest)
withWatchTui :: IO a -> IO a
withWatchTui action = do
interactive <- hIsTerminalDevice stdout
if interactive
then do
originalBuffering <- hGetBuffering stdout
bracket_
(do
hSetBuffering stdout NoBuffering
putStr "\ESC[?1049h\ESC[2J\ESC[H\ESC[?25l"
hFlush stdout)
(do
putStr "\ESC[0m\ESC[?25h\ESC[?1049l"
hFlush stdout
hSetBuffering stdout originalBuffering)
action
else action
renderWatchDashboard ::
IORef (Maybe (TerminalSize, ServerStatus, DashboardState)) ->
FilePath ->
Configuration ->
WatchSettings ->
IORef ServerStatus ->
DashboardState ->
IO ()
renderWatchDashboard renderStateRef projectRoot config watchSettings serverStatusRef dashboard = do
terminalSize <- getTerminalSize
serverStatus <- readIORef serverStatusRef
previousRenderState <- readIORef renderStateRef
let currentRenderState = Just (terminalSize, serverStatus, dashboard)
unless (currentRenderState == previousRenderState) do
let rows = max 1 (terminalRows terminalSize)
cols = max 4 (terminalCols terminalSize)
border = "+" ++ replicate (cols - 2) '-' ++ "+"
infoRows =
[ dashboardRow cols ("Project : " ++ projectRoot),
dashboardRow cols ("Output : " ++ destinationDirectory config),
dashboardRow cols ("Preview : " ++ renderServerStatus watchSettings serverStatus),
dashboardRow cols "Watch : notes/, reference.bib, math-macros.md, images/ (optional)",
dashboardRow cols ("Change : " ++ dashboardLastChange dashboard),
dashboardRow cols ("Build : " ++ dashboardLastBuild dashboard)
]
headerRows =
[ border,
dashboardTitleRow cols "hakysidian watch" (dashboardStatus dashboard),
border
]
++ infoRows
++ [border, dashboardRow cols "Recent activity", border]
footerRows = [border]
availableLogRows = max 1 (rows - length headerRows - length footerRows)
logRows =
map (dashboardRow cols) $
padRows availableLogRows $
takeLast availableLogRows (dashboardLogLines dashboard)
screenRows = take rows (headerRows ++ logRows ++ footerRows)
putStr "\ESC[2J\ESC[H"
putStr (unlines screenRows)
hFlush stdout
writeIORef renderStateRef currentRenderState
dashboardTitleRow :: Int -> String -> String -> String
dashboardTitleRow width leftText rightText =
dashboardFramedRow width (leftText ++ spacer ++ clippedRight)
where
usableWidth = max 1 (width - 4)
rightWidth = min (usableWidth `div` 3) (length rightText)
clippedRight =
if null rightText
then ""
else ellipsize rightWidth rightText
leftWidth = max 1 (usableWidth - length clippedRight - 1)
clippedLeft = ellipsize leftWidth leftText
spacer
| null clippedRight = ""
| otherwise = replicate (max 1 (usableWidth - length clippedLeft - length clippedRight)) ' '
dashboardRow :: Int -> String -> String
dashboardRow width = dashboardFramedRow width
dashboardFramedRow :: Int -> String -> String
dashboardFramedRow width content =
"| " ++ padRight usableWidth (ellipsize usableWidth content) ++ " |"
where
usableWidth = max 1 (width - 4)
padRight :: Int -> String -> String
padRight width text = text ++ replicate (max 0 (width - length text)) ' '
padRows :: Int -> [String] -> [String]
padRows count rows =
rows ++ replicate (max 0 (count - length rows)) ""
takeLast :: Int -> [a] -> [a]
takeLast count xs = drop (max 0 (length xs - count)) xs
ellipsize :: Int -> String -> String
ellipsize width text
| width <= 0 = ""
| length text <= width = text
| width <= 3 = take width text
| otherwise = take (width - 3) text ++ "..."
renderServerStatus :: WatchSettings -> ServerStatus -> String
renderServerStatus watchSettings serverStatus = case serverStatus of
ServerDisabled -> "disabled (--no-server)"
ServerStarting -> maybe "starting" (++ " (starting)") (watchUrl watchSettings)
ServerRunning -> fromMaybe "running" (watchUrl watchSettings)
ServerFailed err ->
"failed: " ++ err
getTerminalSize :: IO TerminalSize
getTerminalSize = do
sttySize <- queryTerminalSize
case sttySize of
Just terminalSize -> pure terminalSize
Nothing -> do
rows <- maybe 24 id . (>>= readMaybe) <$> lookupEnv "LINES"
cols <- maybe 80 id . (>>= readMaybe) <$> lookupEnv "COLUMNS"
pure (TerminalSize rows cols)
queryTerminalSize :: IO (Maybe TerminalSize)
queryTerminalSize = do
result <-
try $
readCreateProcessWithExitCode
(proc "sh" ["-c", "stty size </dev/tty"])
"" :: IO (Either SomeException (ExitCode, String, String))
pure $ do
(exitCode, stdoutText, _) <- either (const Nothing) Just result
case exitCode of
ExitSuccess -> case words stdoutText of
[rowsText, colsText] -> do
rows <- readMaybe rowsText
cols <- readMaybe colsText
Just (TerminalSize rows cols)
_ -> Nothing
ExitFailure _ -> Nothing
watchTimestamp :: IO String
watchTimestamp = formatTime defaultTimeLocale "%H:%M:%S" <$> getZonedTime
trimTrailingSpace :: String -> String
trimTrailingSpace = reverse . dropWhile isSpace . reverse
normalizeLogLines :: String -> String -> [String]
normalizeLogLines stdoutText stderrText =
filter (not . null) $
map trimTrailingSpace $
lines (stdoutText ++ if null stderrText then "" else "\n" ++ stderrText)
appendLogBatch :: DashboardState -> String -> String -> [String] -> DashboardState
appendLogBatch dashboard title timestamp buildLines =
dashboard
{ dashboardLogLines =
takeLast 200 $
dashboardLogLines dashboard
++ ("[" ++ timestamp ++ "] " ++ title)
: map (" " ++) buildLines
}
runCapturedSiteCommand :: FilePath -> String -> IO (ExitCode, [String])
runCapturedSiteCommand projectRoot command = do
executablePath <- getExecutablePath
(exitCode, stdoutText, stderrText) <-
readCreateProcessWithExitCode
(proc executablePath [command]) {cwd = Just projectRoot}
""
pure (exitCode, normalizeLogLines stdoutText stderrText)
buildOptions :: Options buildOptions :: Options
buildOptions = Options {verbosity = False, optCommand = Build RunModeNormal} buildOptions = Options {verbosity = False, optCommand = Build RunModeNormal}
@@ -286,47 +472,126 @@ runSiteCommand config options cslPath =
hakyllWithExitCodeAndArgs config options (siteRules cslPath) hakyllWithExitCodeAndArgs config options (siteRules cslPath)
runWatch :: FilePath -> Configuration -> FilePath -> WatchSettings -> IO ExitCode runWatch :: FilePath -> Configuration -> FilePath -> WatchSettings -> IO ExitCode
runWatch projectRoot config cslPath watchSettings = do runWatch projectRoot config _cslPath watchSettings =
renderWatchPanel projectRoot config watchSettings withWatchTui do
initialExit <- runSiteCommand config buildOptions cslPath serverStatusRef <- newIORef initialServerStatus
when (initialExit /= ExitSuccess) $ renderStateRef <- newIORef Nothing
putStrLn "build : initial build failed; continuing to watch for changes" startPreviewServer config watchSettings serverStatusRef
startPreviewServer config watchSettings renderWatchDashboard renderStateRef projectRoot config watchSettings serverStatusRef initialDashboardState
(_, initialDashboard) <-
runWatchBuild
"build"
"initial build"
"initial build"
projectRoot
config
watchSettings
renderStateRef
serverStatusRef
initialDashboardState
initialSnapshot <- snapshotInputs projectRoot initialSnapshot <- snapshotInputs projectRoot
watchLoop initialSnapshot watchLoop renderStateRef serverStatusRef initialSnapshot initialDashboard
where where
watchLoop :: FileSnapshot -> IO ExitCode initialServerStatus
watchLoop previousSnapshot = do | watchServerEnabled watchSettings = ServerStarting
| otherwise = ServerDisabled
watchLoop ::
IORef (Maybe (TerminalSize, ServerStatus, DashboardState)) ->
IORef ServerStatus ->
FileSnapshot ->
DashboardState ->
IO ExitCode
watchLoop renderStateRef serverStatusRef previousSnapshot dashboard = do
renderWatchDashboard renderStateRef projectRoot config watchSettings serverStatusRef dashboard
threadDelay 1000000 threadDelay 1000000
nextSnapshot <- snapshotInputs projectRoot nextSnapshot <- snapshotInputs projectRoot
if nextSnapshot == previousSnapshot if nextSnapshot == previousSnapshot
then watchLoop previousSnapshot then watchLoop renderStateRef serverStatusRef previousSnapshot dashboard
else do else do
let changedFiles = diffSnapshots previousSnapshot nextSnapshot let changedFiles = diffSnapshots previousSnapshot nextSnapshot
options = command :: String
command =
if any (`M.notMember` nextSnapshot) (M.keys previousSnapshot) if any (`M.notMember` nextSnapshot) (M.keys previousSnapshot)
then rebuildOptions then "rebuild"
else buildOptions else "build"
putStrLn ("change : " ++ intercalate ", " changedFiles) changeSummary = intercalate ", " changedFiles
buildExit <- runSiteCommand config options cslPath (_, nextDashboard) <-
putStrLn ("build : " ++ renderBuildResult buildExit) runWatchBuild
watchLoop nextSnapshot command
command
changeSummary
projectRoot
config
watchSettings
renderStateRef
serverStatusRef
dashboard
watchLoop renderStateRef serverStatusRef nextSnapshot nextDashboard
renderBuildResult :: ExitCode -> String renderBuildResult :: ExitCode -> String
renderBuildResult ExitSuccess = "success" renderBuildResult ExitSuccess = "success"
renderBuildResult (ExitFailure code) = "failed (" ++ show code ++ ")" renderBuildResult (ExitFailure code) = "failed (" ++ show code ++ ")"
startPreviewServer :: Configuration -> WatchSettings -> IO () runWatchBuild ::
startPreviewServer config watchSettings String ->
String ->
String ->
FilePath ->
Configuration ->
WatchSettings ->
IORef (Maybe (TerminalSize, ServerStatus, DashboardState)) ->
IORef ServerStatus ->
DashboardState ->
IO (ExitCode, DashboardState)
runWatchBuild command label changeSummary projectRoot config watchSettings renderStateRef serverStatusRef dashboard = do
startedAt <- watchTimestamp
let runningDashboard =
dashboard
{ dashboardStatus = "building (" ++ label ++ ")",
dashboardLastChange = changeSummary,
dashboardLastBuild = "running since " ++ startedAt
}
renderWatchDashboard renderStateRef projectRoot config watchSettings serverStatusRef runningDashboard
(exitCode, buildLines) <- runCapturedSiteCommand projectRoot command
finishedAt <- watchTimestamp
let loggedDashboard =
appendLogBatch runningDashboard (label ++ ": " ++ changeSummary) finishedAt buildLines
completedDashboard =
loggedDashboard
{ dashboardStatus =
if exitCode == ExitSuccess
then "watching"
else "watching after failed " ++ label,
dashboardLastBuild =
renderBuildResult exitCode
++ " at "
++ finishedAt
++ " via "
++ label
}
renderWatchDashboard renderStateRef projectRoot config watchSettings serverStatusRef completedDashboard
pure (exitCode, completedDashboard)
startPreviewServer :: Configuration -> WatchSettings -> IORef ServerStatus -> IO ()
startPreviewServer config watchSettings serverStatusRef
| watchServerEnabled watchSettings = | watchServerEnabled watchSettings =
void $ void $
forkIO $ forkIO $
do
result <-
(try $
Warp.runSettings settings $ Warp.runSettings settings $
staticApp $ staticApp $
previewSettings config (destinationDirectory config) previewSettings config (destinationDirectory config)) ::
| otherwise = pure () IO (Either SomeException ())
case result of
Left err -> writeIORef serverStatusRef (ServerFailed (show err))
Right () -> pure ()
| otherwise = writeIORef serverStatusRef ServerDisabled
where where
settings = settings =
Warp.setBeforeMainLoop (writeIORef serverStatusRef ServerRunning) $
Warp.setPort (watchPort watchSettings) $ Warp.setPort (watchPort watchSettings) $
Warp.setHost (fromString (watchHost watchSettings)) $ Warp.setHost (fromString (watchHost watchSettings)) $
Warp.defaultSettings Warp.defaultSettings