Compare commits

..

2 Commits

Author SHA1 Message Date
3652459503 Add Unix and enable stdin-driven quit in watch TUI 2026-03-29 13:48:43 +08:00
fc4cac00d5 format code 2026-03-29 13:38:58 +08:00
2 changed files with 146 additions and 57 deletions

View File

@@ -33,6 +33,7 @@ executable hakysidian
, filepath , filepath
, process , process
, time , time
, unix
, wai-app-static , wai-app-static
, warp , warp
-- , ghc-syntax-highlighter -- , ghc-syntax-highlighter

View File

@@ -4,12 +4,11 @@
{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE StandaloneKindSignatures #-} {-# LANGUAGE StandaloneKindSignatures #-}
{-# LANGUAGE ViewPatterns #-}
import ChaoDoc import ChaoDoc
import Control.Concurrent (forkIO, threadDelay) import Control.Concurrent (forkIO, threadDelay)
import Control.Exception (SomeException, bracket_, try) import Control.Exception (SomeException, bracket_, try)
import Control.Monad (filterM, unless, void) import Control.Monad (filterM, unless, void, when)
import Data.Char (isSpace) import Data.Char (isSpace)
import Data.IORef (IORef, newIORef, readIORef, writeIORef) import Data.IORef (IORef, newIORef, readIORef, writeIORef)
import Data.Kind (Type) import Data.Kind (Type)
@@ -23,6 +22,7 @@ import Data.Time.Format (defaultTimeLocale, formatTime)
import Data.Time.LocalTime (getZonedTime) import Data.Time.LocalTime (getZonedTime)
import Hakyll import Hakyll
import Hakyll.Core.Runtime (RunMode (RunModeNormal)) import Hakyll.Core.Runtime (RunMode (RunModeNormal))
import Network.Wai.Application.Static (staticApp)
import qualified Network.Wai.Handler.Warp as Warp import qualified Network.Wai.Handler.Warp as Warp
import qualified Paths_hakysidian as Paths import qualified Paths_hakysidian as Paths
import System.Directory import System.Directory
@@ -31,19 +31,32 @@ import System.Directory
doesFileExist, doesFileExist,
getCurrentDirectory, getCurrentDirectory,
getModificationTime, getModificationTime,
listDirectory listDirectory,
) )
import System.Environment (getArgs, getExecutablePath, lookupEnv) 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 System.IO import System.IO
( BufferMode (NoBuffering), ( BufferMode (NoBuffering),
hFlush, hFlush,
hGetBuffering, hGetBuffering,
hGetChar,
hIsTerminalDevice, hIsTerminalDevice,
hSetBuffering, hSetBuffering,
stdout hWaitForInput,
stdin,
stdout,
)
import System.Posix.IO (stdInput)
import System.Posix.Terminal
( TerminalAttributes,
TerminalMode (EnableEcho, ProcessInput),
TerminalState (Immediately),
getTerminalAttributes,
setTerminalAttributes,
withMinInput,
withTime,
withoutMode,
) )
import System.Process (CreateProcess (cwd), proc, readCreateProcessWithExitCode) import System.Process (CreateProcess (cwd), proc, readCreateProcessWithExitCode)
import Text.Pandoc (HTMLMathMethod (MathML), WriterOptions (..), compileTemplate) import Text.Pandoc (HTMLMathMethod (MathML), WriterOptions (..), compileTemplate)
@@ -232,8 +245,8 @@ validateProject projectRoot = do
unless (null missing) $ unless (null missing) $
die $ die $
unlines $ unlines $
"hakysidian is missing required project inputs:" : "hakysidian is missing required project inputs:"
map (" - " ++) missing : map (" - " ++) missing
initialDashboardState :: DashboardState initialDashboardState :: DashboardState
initialDashboardState = initialDashboardState =
@@ -289,22 +302,54 @@ extractOptionValue option = go
withWatchTui :: IO a -> IO a withWatchTui :: IO a -> IO a
withWatchTui action = do withWatchTui action = do
interactive <- hIsTerminalDevice stdout stdoutInteractive <- hIsTerminalDevice stdout
if interactive stdinInteractive <- hIsTerminalDevice stdin
if stdoutInteractive
then do then do
originalBuffering <- hGetBuffering stdout originalBuffering <- hGetBuffering stdout
originalInputBuffering <- hGetBuffering stdin
originalInputMode <-
if stdinInteractive
then Just <$> getTerminalAttributes stdInput
else pure Nothing
bracket_ bracket_
(do ( do
hSetBuffering stdout NoBuffering hSetBuffering stdout NoBuffering
when stdinInteractive do
hSetBuffering stdin NoBuffering
maybe
(pure ())
(\inputMode -> setTerminalAttributes stdInput (watchInputMode inputMode) Immediately)
originalInputMode
putStr "\ESC[?1049h\ESC[2J\ESC[H\ESC[?25l" putStr "\ESC[?1049h\ESC[2J\ESC[H\ESC[?25l"
hFlush stdout) hFlush stdout
(do )
( do
putStr "\ESC[0m\ESC[?25h\ESC[?1049l" putStr "\ESC[0m\ESC[?25h\ESC[?1049l"
hFlush stdout hFlush stdout
hSetBuffering stdout originalBuffering) maybe
(pure ())
(\inputMode -> setTerminalAttributes stdInput inputMode Immediately)
originalInputMode
when stdinInteractive do
hSetBuffering stdin originalInputBuffering
hSetBuffering stdout originalBuffering
)
action action
else action else action
watchInputMode :: TerminalAttributes -> TerminalAttributes
watchInputMode inputMode =
withTime
( withMinInput
( withoutMode
(withoutMode inputMode ProcessInput)
EnableEcho
)
1
)
0
renderWatchDashboard :: renderWatchDashboard ::
IORef (Maybe (TerminalSize, ServerStatus, DashboardState)) -> IORef (Maybe (TerminalSize, ServerStatus, DashboardState)) ->
FilePath -> FilePath ->
@@ -337,7 +382,11 @@ renderWatchDashboard renderStateRef projectRoot config watchSettings serverStatu
] ]
++ infoRows ++ infoRows
++ [border, dashboardRow cols "Recent activity", border] ++ [border, dashboardRow cols "Recent activity", border]
footerRows = [border] footerRows =
[ border,
dashboardRow cols "Controls: q quit, Ctrl-C interrupt",
border
]
availableLogRows = max 1 (rows - length headerRows - length footerRows) availableLogRows = max 1 (rows - length headerRows - length footerRows)
logRows = logRows =
map (dashboardRow cols) $ map (dashboardRow cols) $
@@ -351,7 +400,7 @@ renderWatchDashboard renderStateRef projectRoot config watchSettings serverStatu
dashboardTitleRow :: Int -> String -> String -> String dashboardTitleRow :: Int -> String -> String -> String
dashboardTitleRow width leftText rightText = dashboardTitleRow width leftText rightText =
dashboardFramedRow width (leftText ++ spacer ++ clippedRight) dashboardRow width (leftText ++ spacer ++ clippedRight)
where where
usableWidth = max 1 (width - 4) usableWidth = max 1 (width - 4)
rightWidth = min (usableWidth `div` 3) (length rightText) rightWidth = min (usableWidth `div` 3) (length rightText)
@@ -366,10 +415,7 @@ dashboardTitleRow width leftText rightText =
| otherwise = replicate (max 1 (usableWidth - length clippedLeft - length clippedRight)) ' ' | otherwise = replicate (max 1 (usableWidth - length clippedLeft - length clippedRight)) ' '
dashboardRow :: Int -> String -> String dashboardRow :: Int -> String -> String
dashboardRow width = dashboardFramedRow width dashboardRow width content =
dashboardFramedRow :: Int -> String -> String
dashboardFramedRow width content =
"| " ++ padRight usableWidth (ellipsize usableWidth content) ++ " |" "| " ++ padRight usableWidth (ellipsize usableWidth content) ++ " |"
where where
usableWidth = max 1 (width - 4) usableWidth = max 1 (width - 4)
@@ -405,8 +451,8 @@ getTerminalSize = do
case sttySize of case sttySize of
Just terminalSize -> pure terminalSize Just terminalSize -> pure terminalSize
Nothing -> do Nothing -> do
rows <- maybe 24 id . (>>= readMaybe) <$> lookupEnv "LINES" rows <- fromMaybe 24 . (>>= readMaybe) <$> lookupEnv "LINES"
cols <- maybe 80 id . (>>= readMaybe) <$> lookupEnv "COLUMNS" cols <- fromMaybe 80 . (>>= readMaybe) <$> lookupEnv "COLUMNS"
pure (TerminalSize rows cols) pure (TerminalSize rows cols)
queryTerminalSize :: IO (Maybe TerminalSize) queryTerminalSize :: IO (Maybe TerminalSize)
@@ -415,7 +461,8 @@ queryTerminalSize = do
try $ try $
readCreateProcessWithExitCode readCreateProcessWithExitCode
(proc "sh" ["-c", "stty size </dev/tty"]) (proc "sh" ["-c", "stty size </dev/tty"])
"" :: IO (Either SomeException (ExitCode, String, String)) "" ::
IO (Either SomeException (ExitCode, String, String))
pure $ do pure $ do
(exitCode, stdoutText, _) <- either (const Nothing) Just result (exitCode, stdoutText, _) <- either (const Nothing) Just result
case exitCode of case exitCode of
@@ -430,6 +477,38 @@ queryTerminalSize = do
watchTimestamp :: IO String watchTimestamp :: IO String
watchTimestamp = formatTime defaultTimeLocale "%H:%M:%S" <$> getZonedTime watchTimestamp = formatTime defaultTimeLocale "%H:%M:%S" <$> getZonedTime
watchLoopDelayMicros :: Int
watchLoopDelayMicros = 1000000
watchInputPollMicros :: Int
watchInputPollMicros = 100000
waitForWatchQuit :: Bool -> Int -> IO Bool
waitForWatchQuit watchInputEnabled remainingMicros
| remainingMicros <= 0 = pure False
| otherwise = do
shouldQuit <- pollWatchQuit watchInputEnabled
if shouldQuit
then pure True
else do
threadDelay (min watchInputPollMicros remainingMicros)
waitForWatchQuit watchInputEnabled (remainingMicros - watchInputPollMicros)
pollWatchQuit :: Bool -> IO Bool
pollWatchQuit watchInputEnabled
| not watchInputEnabled = pure False
| otherwise = drainInput
where
drainInput = do
hasInput <- hWaitForInput stdin 0
if hasInput
then do
inputChar <- hGetChar stdin
if inputChar == 'q'
then pure True
else drainInput
else pure False
trimTrailingSpace :: String -> String trimTrailingSpace :: String -> String
trimTrailingSpace = reverse . dropWhile isSpace . reverse trimTrailingSpace = reverse . dropWhile isSpace . reverse
@@ -472,7 +551,10 @@ 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 = runWatch projectRoot config _cslPath watchSettings = do
stdoutInteractive <- hIsTerminalDevice stdout
stdinInteractive <- hIsTerminalDevice stdin
let watchInputEnabled = stdoutInteractive && stdinInteractive
withWatchTui do withWatchTui do
serverStatusRef <- newIORef initialServerStatus serverStatusRef <- newIORef initialServerStatus
renderStateRef <- newIORef Nothing renderStateRef <- newIORef Nothing
@@ -490,44 +572,48 @@ runWatch projectRoot config _cslPath watchSettings =
serverStatusRef serverStatusRef
initialDashboardState initialDashboardState
initialSnapshot <- snapshotInputs projectRoot initialSnapshot <- snapshotInputs projectRoot
watchLoop renderStateRef serverStatusRef initialSnapshot initialDashboard watchLoop watchInputEnabled renderStateRef serverStatusRef initialSnapshot initialDashboard
where where
initialServerStatus initialServerStatus
| watchServerEnabled watchSettings = ServerStarting | watchServerEnabled watchSettings = ServerStarting
| otherwise = ServerDisabled | otherwise = ServerDisabled
watchLoop :: watchLoop ::
Bool ->
IORef (Maybe (TerminalSize, ServerStatus, DashboardState)) -> IORef (Maybe (TerminalSize, ServerStatus, DashboardState)) ->
IORef ServerStatus -> IORef ServerStatus ->
FileSnapshot -> FileSnapshot ->
DashboardState -> DashboardState ->
IO ExitCode IO ExitCode
watchLoop renderStateRef serverStatusRef previousSnapshot dashboard = do watchLoop watchInputEnabled renderStateRef serverStatusRef previousSnapshot dashboard = do
renderWatchDashboard renderStateRef projectRoot config watchSettings serverStatusRef dashboard renderWatchDashboard renderStateRef projectRoot config watchSettings serverStatusRef dashboard
threadDelay 1000000 shouldQuit <- waitForWatchQuit watchInputEnabled watchLoopDelayMicros
nextSnapshot <- snapshotInputs projectRoot if shouldQuit
if nextSnapshot == previousSnapshot then pure ExitSuccess
then watchLoop renderStateRef serverStatusRef previousSnapshot dashboard
else do else do
let changedFiles = diffSnapshots previousSnapshot nextSnapshot nextSnapshot <- snapshotInputs projectRoot
command :: String if nextSnapshot == previousSnapshot
command = then watchLoop watchInputEnabled renderStateRef serverStatusRef previousSnapshot dashboard
if any (`M.notMember` nextSnapshot) (M.keys previousSnapshot) else do
then "rebuild" let changedFiles = diffSnapshots previousSnapshot nextSnapshot
else "build" command :: String
changeSummary = intercalate ", " changedFiles command =
(_, nextDashboard) <- if any (`M.notMember` nextSnapshot) (M.keys previousSnapshot)
runWatchBuild then "rebuild"
command else "build"
command changeSummary = intercalate ", " changedFiles
changeSummary (_, nextDashboard) <-
projectRoot runWatchBuild
config command
watchSettings command
renderStateRef changeSummary
serverStatusRef projectRoot
dashboard config
watchLoop renderStateRef serverStatusRef nextSnapshot nextDashboard watchSettings
renderStateRef
serverStatusRef
dashboard
watchLoop watchInputEnabled renderStateRef serverStatusRef nextSnapshot nextDashboard
renderBuildResult :: ExitCode -> String renderBuildResult :: ExitCode -> String
renderBuildResult ExitSuccess = "success" renderBuildResult ExitSuccess = "success"
@@ -580,10 +666,11 @@ startPreviewServer config watchSettings serverStatusRef
forkIO $ forkIO $
do do
result <- result <-
(try $ ( try $
Warp.runSettings settings $ Warp.runSettings settings $
staticApp $ staticApp $
previewSettings config (destinationDirectory config)) :: previewSettings config (destinationDirectory config)
) ::
IO (Either SomeException ()) IO (Either SomeException ())
case result of case result of
Left err -> writeIORef serverStatusRef (ServerFailed (show err)) Left err -> writeIORef serverStatusRef (ServerFailed (show err))
@@ -592,9 +679,10 @@ startPreviewServer config watchSettings serverStatusRef
where where
settings = settings =
Warp.setBeforeMainLoop (writeIORef serverStatusRef ServerRunning) $ Warp.setBeforeMainLoop (writeIORef serverStatusRef ServerRunning) $
Warp.setPort (watchPort watchSettings) $ Warp.setPort (watchPort watchSettings) $
Warp.setHost (fromString (watchHost watchSettings)) $ Warp.setHost
Warp.defaultSettings (fromString (watchHost watchSettings))
Warp.defaultSettings
snapshotInputs :: FilePath -> IO FileSnapshot snapshotInputs :: FilePath -> IO FileSnapshot
snapshotInputs projectRoot = do snapshotInputs projectRoot = do
@@ -625,8 +713,8 @@ trackedFilesIn root = do
if exists if exists
then do then do
entries <- listDirectory root entries <- listDirectory root
fmap concat $ fmap concat
traverse <$> traverse
( \name -> do ( \name -> do
let path = root </> name let path = root </> name
isDir <- doesDirectoryExist path isDir <- doesDirectoryExist path