Skip to content

Commit 61d2a6a

Browse files
committed
adding watch command
1 parent e5889cf commit 61d2a6a

File tree

2 files changed

+194
-0
lines changed

2 files changed

+194
-0
lines changed

src/docfx/Models/WatchCommand.cs

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Docfx.Common;
5+
using Spectre.Console.Cli;
6+
7+
namespace Docfx;
8+
9+
internal class WatchCommand : Command<WatchCommandOptions>
10+
{
11+
public override int Execute(CommandContext context, WatchCommandOptions settings)
12+
{
13+
return CommandHelper.Run(settings, () =>
14+
{
15+
var (config, baseDirectory) = Docset.GetConfig(settings.ConfigFile);
16+
BuildCommand.MergeOptionsToConfig(settings, config.build, baseDirectory);
17+
var conf = new BuildOptions();
18+
var serveDirectory = RunBuild.Exec(config.build, conf, baseDirectory, settings.OutputFolder);
19+
20+
void onChange()
21+
{
22+
RunBuild.Exec(config.build, conf, baseDirectory, settings.OutputFolder);
23+
}
24+
25+
if (settings is { Serve: true, Watch: true })
26+
{
27+
using var watcher = Watch(baseDirectory, config.build, onChange);
28+
Serve(serveDirectory, settings.Host, settings.Port, settings.OpenBrowser, settings.OpenFile);
29+
}
30+
else if (settings.Watch)
31+
{
32+
using var watcher = Watch(baseDirectory, config.build, onChange);
33+
34+
// just block but here we can't use the host mecanism
35+
// since we didn't start the server so use console one
36+
using var canceller = new CancellationTokenSource();
37+
Console.CancelKeyPress += (sender, args) => canceller.Cancel();
38+
Task.Delay(Timeout.Infinite, canceller.Token).Wait();
39+
}
40+
else if (settings.Serve)
41+
{
42+
RunServe.Exec(serveDirectory, settings.Host, settings.Port, settings.OpenBrowser, settings.OpenFile);
43+
}
44+
else
45+
{
46+
onChange();
47+
}
48+
});
49+
}
50+
51+
internal void Serve(string serveDirectory, string host, int? port, bool openBrowser, string openFile) {
52+
if (CommandHelper.IsTcpPortAlreadyUsed(host, port))
53+
{
54+
Logger.LogError($"Serve option specified. But TCP port {port ?? 8080} is already being in use.");
55+
return;
56+
}
57+
RunServe.Exec(serveDirectory, host, port, openBrowser, openFile);
58+
}
59+
60+
// For now it is a simplistic implementation, in particular on the glob to filter mappping
61+
// but it should be sufficient for most cases.
62+
internal static IDisposable Watch(string baseDir, BuildJsonConfig config, Action onChange)
63+
{
64+
FileSystemWatcher watcher = new(baseDir)
65+
{
66+
IncludeSubdirectories = true,
67+
NotifyFilter = NotifyFilters.Attributes | NotifyFilters.Size | NotifyFilters.FileName |
68+
NotifyFilters.DirectoryName | NotifyFilters.LastWrite
69+
};
70+
71+
if (WatchAll(config))
72+
{
73+
watcher.Filters.Add("*.*");
74+
}
75+
else
76+
{
77+
RegisterFiles(watcher, config.Content);
78+
RegisterFiles(watcher, config.Resource);
79+
80+
IEnumerable<string> forcedFiles = ["docfx.json", "*.md", "toc.yml"];
81+
foreach (var forcedFile in forcedFiles)
82+
{
83+
if (!watcher.Filters.Any(f => f == forcedFile))
84+
{
85+
watcher.Filters.Add(forcedFile);
86+
}
87+
}
88+
}
89+
90+
// avoid to call onChange() in chain so await "last" event before re-rendering
91+
var cancellation = new CancellationTokenSource[] { null };
92+
async void debounce()
93+
{
94+
var token = new CancellationTokenSource();
95+
lock (cancellation)
96+
{
97+
ResetToken(cancellation);
98+
cancellation[0] = token;
99+
}
100+
101+
await Task.Delay(100, token.Token);
102+
if (!token.IsCancellationRequested)
103+
{
104+
onChange();
105+
}
106+
}
107+
108+
watcher.Changed += (_, _) => debounce();
109+
watcher.Created += (_, _) => debounce();
110+
watcher.Deleted += (_, _) => debounce();
111+
watcher.Renamed += (_, _) => debounce();
112+
watcher.EnableRaisingEvents = true;
113+
114+
return new DisposableAction(() =>
115+
{
116+
watcher.Dispose();
117+
lock (cancellation)
118+
{
119+
ResetToken(cancellation);
120+
}
121+
});
122+
}
123+
124+
private static void ResetToken(CancellationTokenSource[] cancellation)
125+
{
126+
var token = cancellation[0];
127+
if (token is not null && !token.IsCancellationRequested)
128+
{
129+
token.Cancel();
130+
token.Dispose();
131+
}
132+
}
133+
134+
internal static bool WatchAll(BuildJsonConfig config)
135+
{
136+
return ((IEnumerable<FileMapping>)[config.Resource, config.Content])
137+
.Where(it => it is not null)
138+
.SelectMany(it => it.Items)
139+
.SelectMany(it => it.Files)
140+
.Any(it => it.EndsWith("**"));
141+
}
142+
143+
internal static void RegisterFiles(FileSystemWatcher watcher, FileMapping content)
144+
{
145+
foreach (var pattern in content?
146+
.Items?
147+
.SelectMany(it => it.Files)
148+
.SelectMany(SanitizePatternForWatcher)
149+
.Distinct()
150+
.ToList())
151+
{
152+
watcher.Filters.Add(pattern);
153+
}
154+
}
155+
156+
// as of now it can list too much files but will less hurt to render more often with deboucning
157+
// than not rendering when needed.
158+
internal static IEnumerable<string> SanitizePatternForWatcher(string file)
159+
{
160+
var name = file[(file.LastIndexOf('.') + 1)..]; // "**/images/**/*.png" => "*.png"
161+
if (name.EndsWith('}')) // "**/*.{md,yml}" => "*.md" and "*.yml"
162+
{
163+
var start = name.IndexOf('{');
164+
if (start > 0)
165+
{
166+
var prefix = file[0..start];
167+
return file[(start + 1)..^1]
168+
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
169+
.Select(extension => $"{prefix}{extension}");
170+
}
171+
}
172+
return [name];
173+
}
174+
175+
internal class DisposableAction(Action action) : IDisposable
176+
{
177+
public void Dispose() => action();
178+
}
179+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.ComponentModel;
5+
using Spectre.Console.Cli;
6+
7+
namespace Docfx;
8+
9+
[Description("Generate client-only website combining API in YAML files and conceptual files and watch them for changes")]
10+
internal class WatchCommandOptions : BuildCommandOptions
11+
{
12+
[Description("Should directory be watched and website re-rendered on changes.")]
13+
[CommandOption("-w|--watch")]
14+
public bool Watch { get; set; }
15+
}

0 commit comments

Comments
 (0)